Guide

L2TPv2 LNS subsystem

Ze includes a native L2TPv2 (RFC 2661) LNS/LAC implementation used as a BNG (Broadband Network Gateway) component: it terminates L2TP tunnels over UDP, runs PPP negotiation (LCP, authentication, IPCP/IPv6CP), assigns peer IPs, and hands the kernel data plane to the l2tp_ppp module via netlink. Subscriber routes are tracked for redistribution into the protocol RIB.

This page covers operator-facing use: configuration, the CLI surface, PPP negotiation, authentication, IP pools, traffic shaping, RADIUS, metrics, the web UI, reload semantics, and how subscriber routes flow out to BGP.

Configuration

The l2tp {} container carries protocol settings; listener endpoints live under environment { l2tp { server ... } }:

l2tp {
    enabled true;
    shared-secret <secret>;     // CHAP-MD5 tunnel auth (RFC 2661 S4.2)
    auth-method chap-md5;        // PPP Auth-Protocol first advertised
    allow-no-auth false;         // explicit opt-in required for no-auth
    hello-interval 60;           // seconds of peer silence before HELLO
    hello-retries 2;             // unanswered HELLO intervals before dead-peer teardown (0 disables)
    max-tunnels 1024;            // 0 explicitly means unbounded
    max-sessions 1024;           // per-tunnel, 0 explicitly means unbounded
}

environment {
    l2tp {
        server main {
            ip 0.0.0.0;
            port 1701;
        }
    }
}

Presence of l2tp {} with any content implies the subsystem is enabled. Set enabled false to disable explicitly. Listener endpoints use the standard zt:listener grouping (ip, port) with port conflict detection via ze:listener.

Initiator: dial targets and outgoing calls

Beyond answering tunnels, ze can INITIATE them (send SCCRQ) toward a configured remote. A remote list under l2tp {} declares dial targets; declaring one grants no dial by itself. An operator action (RPC) or a PPPoE relay binding drives the dial.

l2tp {
    enabled true;
    remote lns-retail {
        address 203.0.113.10;   // control-plane IP to dial (SCCRQ destination)
        port 1701;              // remote UDP port (default 1701)
        shared-secret <secret>; // per-remote CHAP-MD5 (empty = no Challenge)
        outgoing-calls true;    // permit `request l2tp outgoing-call` to this remote
    }
    relay <service-name> {      // PPPoE Service-Name -> L2TP incoming call (LAC)
        remote lns-retail;      // must reference an l2tp/remote above
    }
}

Place an LNS-side outgoing call (RFC 2661 S10.4) with:

request l2tp outgoing-call remote lns-retail called <number>

The command dials the remote if no tunnel is up, sends an OCRQ, and blocks until the call establishes (returning the local/remote session IDs) or fails (returning the cause and RFC 2661 Result Code: tunnel auth reject, tie-breaker loss, peer CDN, or timeout). The remote must have outgoing-calls true.

The relay binding is the LAC path: a PPPoE subscriber whose Service-Name matches is relayed into an L2TP incoming call (ICRQ) toward the bound remote instead of terminating PPP locally. The subscriber-PPP↔L2TP kernel channel bridge that carries frames at the LAC is Linux-only and exercised through ./le test deployment l2tp-ppp-test. Initiator tunnel interop is proven against xl2tpd in test/interop-l2tp/scenarios/03-ze-lac-xl2tpd-lns.

Rejecting a malformed SCCRQ

RFC 2661 Section 6.1 makes five AVPs mandatory in an SCCRQ: Message Type, Protocol Version, Host Name, Framing Capabilities and Assigned Tunnel ID. An SCCRQ that omits one of them is a malformed control message under Section 7.1, and so is one whose value ze cannot read: a 3-octet Framing Capabilities AVP, an empty Host Name AVP, or an Assigned Tunnel ID of 0, which Section 4.4.3 makes "a 2 octet non-zero unsigned integer".

Ze answers each of these with a StopCCN that carries Result Code 2 (general error, see the Error Code), Error Code 3 (a field value out of range) and an Error Message that names the AVP, for example SCCRQ missing Framing Capabilities AVP. The reply goes out with Tunnel ID 0, because the peer supplied no tunnel id ze can address it by, and no tunnel entry is created for it.

An SCCRQ carrying a vendor-specific AVP ze does not recognize with the M-bit set is answered on the same path, with Error Code 8 rather than 3: RFC 2661 Section 4.4.2 gives that code to a "tunnel was shutdown due to receipt of an unknown AVP with the M-bit set", and Section 4.2 says the same AVP with the M-bit clear is ignored and the message accepted, which is what ze does. Each refusal states its own Error Code, so the code names the fault the peer has to correct.

The reply is rate-bounded: one StopCCN per source-address slot per second, over a fixed 256-slot table. A spoofed SCCRQ flood therefore allocates nothing and draws at most 256 replies per second from the whole reactor. A TunnelID=0 datagram whose message type is not SCCRQ keeps its silent drop.

CLI commands

Read commands

Command Output
show l2tp Aggregate counters: tunnel count, session count, listener count
show l2tp tunnels Array of tunnel summaries
show l2tp tunnel <id> One tunnel detail (AVPs, capabilities, session list)
show l2tp sessions Array of session summaries (flattened across tunnels)
show l2tp session <id> One session detail (PPP state, assigned IP, speeds)
show l2tp statistics Protocol counters (tunnel/session counts, per-message stats)
show l2tp listeners Bound UDP endpoints
show l2tp config Effective runtime config; shared-secret redacted to <set>/<unset>

Destructive commands

Command Effect
clear l2tp tunnel id <id> Sends StopCCN Result Code 6 (administrative) to the named tunnel
clear l2tp tunnel all Same, for every live tunnel
clear l2tp session id <id> [reason <text...>] [cause <code>] Sends CDN Result Code 3 (administrative) to the named session
clear l2tp session all Same, for every live session (tunnels remain)

The clear l2tp session id command accepts optional keyword arguments:

RFC 2661 S4.4.2 / S5.4.2 define the Result Codes and teardown semantics. Teardown of unknown IDs returns an error naming the ID.

Destructive commands live under the clear verb (not a top-level l2tp noun) to match ze's CLI grammar. The clear prefix is denied in the built-in read-only authz profile.

Offline dispatcher

ze l2tp show ... and the ze l2tp tunnel|session {id <id>|all} commands forward to the running daemon via SSH. Output is the same JSON the daemon handler returns. Each accepts --user <name> (short alias -u) to name the SSH login user; without it the login resolves to the zefs super-admin. The flag must come before the subcommand arguments (ze l2tp show --user alice tunnels), and one left in the positional tail is rejected rather than forwarded, so a misplaced --user never silently answers for the default user. Shell completion offers the flag on all three verbs. (Inside the daemon CLI these dispatch as clear l2tp tunnel|session {id <id>|all} ...; clear already means tear down, so no teardown token is needed.) ze l2tp decode is an offline wire-decode tool that does not require a running daemon.

echo c8020044... | ze l2tp decode --pretty

PPP negotiation

When a session is established (ICRQ/ICRP/ICCN exchange), the subsystem creates kernel L2TP tunnel and session resources via Generic Netlink, then opens a PPPoL2TP socket and attaches a /dev/ppp channel and unit. The kernel creates a pppN interface.

PPP negotiation proceeds through these phases:

  1. LCP (RFC 1661) -- MRU, authentication method (PAP/CHAP-MD5/MS-CHAPv2), magic number, echo keepalive. 10-state FSM with ~30 transitions.
  2. Authentication -- PAP (RFC 1334), CHAP-MD5 (RFC 1994), or MS-CHAPv2 (RFC 2759). Dispatched to the registered auth handler (l2tp-auth-local or l2tp-auth-radius plugin).
  3. IPCP (RFC 1332) -- IPv4 address assignment + DNS options (RFC 1877). Dispatched to the registered pool handler (l2tp-pool plugin).
  4. IPv6CP (RFC 5072) -- interface identifier negotiation. Runs in parallel with IPCP when both NCPs are enabled. NCPs are independent (RFC 1661 S2): if the pool handler declines IPv6 (for example an IPv4-only static pool), IPv6CP is dropped and the session stays up with IPv4 alone rather than being torn down.

The PPP MRU counts the Information field, not the two-octet Protocol field. Ze's userspace buffers hold 1502 octets, and the kernel receive MRU stays at 1500 even when a smaller MRU was requested. The outgoing IP interface MTU comes from the peer's negotiated MRU without subtracting PPP framing.

PPP descriptors use blocking os.File I/O rather than the runtime epoll path. Closing a session prevents new operations while an in-flight blocking syscall retains its descriptor reference. This keeps a late read or repeated close from using a descriptor number the process has since recycled.

The PPP driver closes both the channel and unit on natural exit or explicit stop. Shutdown, channel attachment and detachment, and post-NCP interface setup share a lock so an ioctl cannot use a recycled descriptor.

The L2TP kernel worker transfers the channel and unit to PPP only after the reactor confirms that the original session is still present. The transfer clears those descriptors from the worker; its teardown still closes the PPPoX transport. Bridged sessions and sessions without a PPP driver stay worker-owned. A cancelled delivery closes the claimed descriptors; an accepted queue item belongs to PPP even if rejected or drained during stop.

LCP accepts an Ack only when its Identifier and complete option bytes match the last request. Nak and Reject replies also need the matching Identifier; Reject options must be an unchanged, ordered subset of that request. Rejected options stay out of subsequent requests. Unanswered retransmissions retain their Identifier, while a changed request or valid reply advances it. Echo packets carry zero until Magic-Number negotiation succeeds, then the local Magic-Number; received Echo packets must carry the peer's value.

LCP enters Opened before authentication and NCP execute. If those phases receive an LCP restart or termination, they unwind into the new LCP state instead of restoring Opened. Periodic CHAP and MS-CHAPv2 authentication follow the same rule: the session loop resumes LCP negotiation or waits for the termination grace timer before notifying the transport to close. Echo and periodic authentication timers stop while LCP is outside Opened.

Leaving LCP Opened ends the previous PAP decision and both NCP lifetimes. Ze disconnects an attached channel from its PPP unit before replacement authentication and NCP, so control frames return to the channel reader. A failed disconnect or interface-resource removal ends the session instead of reusing the unit. Ze removes IPCP interface resources and stops the IPv6 service before clearing their negotiation state and cached frames. An LCP-down event then notifies the transport that the previous network phase has ended. If NCP had started, PPP waits for the transport to acknowledge resource cleanup before continuing LCP. This prevents old cleanup from removing a replacement allocation or profile for the same session.

NCP packets received before the new authentication completes are discarded, including while LCP is already Opened. Once NCP restarts, Configure replies must match the Identifier of its latest transmitted request. An Ack must also match all request options exactly; Reject options must remain an unchanged, ordered subset. These checks prevent an old reply from completing the replacement negotiation or changing its addresses.

Ze answers each RFC 5072 Section 4.1 comparison outcome for the peer's Interface-Identifier option:

The peer's Configure-Request Ze answers
A non-zero identifier that differs from Ze's Configure-Ack, and the identifier is recorded as negotiated
A zero identifier, or one equal to Ze's Configure-Nak carrying a freshly drawn non-zero identifier with the "u" bit clear
✕ Configure-Nak carrying a suggestion the FIRST time. Every later request that still omits it is Acked, so a client whose implementation does not support the option converges rather than being asked forever
Two Interface-Identifier options Refused, the same as any other malformed option list
A zero identifier while Ze's own is also zero Configure-Reject with the identifier set to zero, which ends that negotiation

A session can reach IPv6CP Opened without the peer ever supplying an identifier, through the missing-option case above. Ze starts no IPv6 service, installs no route and publishes no IPv6 address for such a session: an all-zero identifier would make the peer's link-local address fe80::, which is not the subscriber's. The session stays up with IPv4 alone, and the refusal is logged and counted (see Prometheus metrics).

Authentication and NCP have configurable timeouts. LCP negotiation and restart timers are fixed. LCP proxy (RFC 2661 S18) is supported: when the LAC provides proxy LCP AVPs, Ze validates them and can renegotiate instead of accepting them.

NCP enablement is controlled via the ncp container under l2tp:

l2tp {
    ncp {
        enable-ipcp true
        enable-ipv6cp true
        timeout 30
    }
}

Authentication

Ze separates PPP authentication wire format from credential validation. The subsystem handles the PPP auth protocol (PAP/CHAP wire framing), then dispatches an EventAuthRequest to the registered auth handler. The handler responds with accept/reject via a channel.

By default, new sessions advertise auth-method chap-md5 and allow-no-auth false. If a peer rejects every acceptable Auth-Protocol, the PPP session is disconnected after LCP instead of falling through to the no-auth accounting path. Set allow-no-auth true only for lab peers or explicit no-auth deployments; auth-method none is rejected unless that opt-in is present.

Two auth handlers ship with ze. The slot holds one handler, and configuration decides its owner: l2tp-auth-radius claims it when a RADIUS server is configured, and l2tp-auth-local keeps it otherwise. Both transports resolve that slot for each authentication request. Applying a RADIUS configuration switches subsequent requests to RADIUS; removing the auth radius block returns them to the current local user table, including credentials changed while RADIUS owned the slot. An in-process RADIUS plugin restart preserves that local fallback.

l2tp-auth-local

Built-in static user list with PAP and CHAP-MD5 support. Configured under the l2tp config tree:

l2tp {
    auth {
        local {
            user alice {
                password hunter2;
            }
        }
    }
}

PAP and CHAP-MD5 require a configured user. Without one, the local handler rejects authentication. Explicit no-auth sessions need no user entry; allow-no-auth true must permit them.

l2tp-auth-radius

RADIUS client plugin providing:

Configure l2tp-auth-radius as an internal plugin. It refuses external startup because authentication and accounting require the engine's process-local state.

The RADIUS handler accepts explicit no-auth sessions without an Access-Request. RADIUS remains available for accounting.

For sessions that require authentication, RFC 2865 Section 4.1 requires User-Password, CHAP-Password, State, or authentication information defined by an extension. The handler denies a request without a usable credential with no usable credential. Section 5 forbids text of length zero, so an empty PAP Peer-ID produces an Access-Request without User-Name rather than an empty User-Name attribute.

An Access-Accept naming a Service-Type other than Framed-User is treated as an Access-Reject, and the session is denied with unsupported Service-Type. The LNS provides framed PPP access and asks for it by name, so an Accept authorizing anything else authorizes a service ze cannot bring up (RFC 2865 Sections 5.6 and 1.1).

For MS-CHAPv2 the Accept must also carry a readable MS-CHAP2-Success, and one that does not is denied with no MS-CHAP2-Success in Access-Accept. RFC 2759 completes the method with a 20-octet Authenticator Response the client checks, so an Accept without one authorizes a session neither end can finish. The attribute is not that value: RFC 2548 Section 2.3.3 defines it as an Ident octet followed by the string S= and the response as 40 hexadecimal digits, so ze strips the Ident and decodes the digits rather than passing the attribute through.

Every Accounting-Request carries Acct-Status-Type, Acct-Session-Id, Service-Type (Framed), Framed-Protocol (PPP), NAS-Port-Type (Virtual), NAS-Port and Event-Timestamp. User-Name and Calling-Station-Id are added when the session has one: a session the LNS never authenticated has no username, a call whose peer sent no Calling Number AVP has no station id, and RFC 2866 Section 5 forbids sending text of length zero. NAS-Port-Id is added when the operator configured a template. Stop and Interim-Update add Acct-Session-Time. Their traffic counters measure the current network lifetime, excluding traffic from an earlier lifetime on the same PPP unit. Display-counter clears do not change them. RFC 2869 gigaword counters carry the upper 32 bits when nonzero. If the counter baseline or a later snapshot is unavailable, Ze logs the failure and omits the traffic counters instead of reporting zero.

PPP waits for address publication and the L2TP accounting callback to capture the initial raw interface counters before it enables forwarding. This includes a replacement network lifetime on the same PPP unit. Accounting-Start runs asynchronously; forwarding does not wait for a RADIUS server response. PPPoE accounting remains unconnected to subscriber events.

Event-Timestamp (type 55) is four octets of seconds since 1970-01-01 00:00 UTC, which RFC 2869 Section 5.3 defines. Calling-Station-Id (type 31) is the L2TP Calling Number AVP the peer sent, or the subscriber MAC address on the PPPoE relay path.

Acct-Delay-Time (type 41) reports how many seconds ze has been trying to send that record, counted from its first attempt, which is what RFC 2866 Section 5.2 asks for. It is zero on the first transmission and it is updated on every retransmission. RFC 2866 Section 4.1 makes that update change the Identifier and the Request Authenticator as well, so a server that sees a repeat of an accounting record sees a new Identifier. An Access-Request is unaffected: its retransmission is byte for byte the first packet, with the same Identifier, which is what RFC 2865 Section 2.5 requires.

The Stop record adds Acct-Terminate-Cause (type 49), and no other record carries it. RFC 2866 Section 5.10: "This attribute indicates how the session was terminated, and can only be present in Accounting-Request records where the Acct-Status-Type is set to Stop." The value is one of that section's integers: User Request (1) when LCP reaches Closed or Stopped, Lost Carrier (2) for unanswered LCP echo probes and for a peer that stops answering at the tunnel level, Lost Service (3) for every session on a tunnel the peer ended, Idle Timeout (4) and Session Timeout (5) for the two RADIUS timers, Admin Reset (6) for an operator clear and for a RADIUS Disconnect-Request, Admin Reboot (7) for the sessions ze stops on shutdown, NAS Request (10) when the PPP driver stops a running session, and NAS Error (9) for a failure ze detected or a teardown it cannot attribute. Ze reports NAS Error rather than guessing a more specific cause.

A subscriber hanging up reaches the record through the peer's Call-Disconnect- Notify. Ze reads that message's Result Code (RFC 2661 Section 4.4.2) and translates the two values RFC 2866 Section 5.10 also states: code 1, "Call disconnected due to loss of carrier", becomes Lost Carrier, and code 3, "Call disconnected for administrative reasons", becomes Admin Reset. The other ten codes describe a call the far end could not place, which no Section 5.10 cause states, so they become NAS Error rather than a guess.

Each of these four attributes is sent unless the operator holds it back with attributes exclude, described below. There is no leaf that turns one on: the default is to send it.

RFC 2866 Section 4.1 requires either NAS-IP-Address or NAS-Identifier in every Accounting-Request, and RFC 2865 Section 4.1 requires the same of every Access-Request, so ze always sends one of the two. A configured source-address becomes NAS-IP-Address and a configured nas-identifier becomes NAS-Identifier. When neither leaf is set, the host name is sent as NAS-Identifier, which is what the RADIUS admin backend already does.

Framed-IP-Address carries the address the session was actually given, which RFC 2866 Section 4.1 requires of the attribute. The value is the IPCP-negotiated peer address the reactor put on pppN. RFC 2865 Section 5.8 makes the attribute four octets, so a session with no address yet, or one whose only assignment is IPv6, sends no attribute rather than a wrong one.

Holding an attribute back

The four attributes above are sent by default, which is what a billing system usually wants. An operator whose server or billing pipeline does not want one of them writes an attributes exclude container beside server:

l2tp {
    auth {
        radius {
            attributes {
                exclude {
                    calling-station-id {
                        packet-type [ accounting-interim ];
                    }
                    acct-terminate-cause;
                }
            }
        }
    }
}

Naming an attribute alone holds it back from every packet that would carry it. Adding a packet-type list holds it back from the record types named there and keeps it in the others.

Six attributes can be held back, and each accepts only the record types Ze puts it in. A line naming any other record type is refused when the configuration is committed.

Attribute Record types it accepts What it feeds on the server
calling-station-id (31) the three accounting records the line the subscriber called from, and the key FreeRADIUS matches its IP pool rows on
event-timestamp (55) the three accounting records when the event happened on the NAS, where a server would otherwise use its own receive time
acct-delay-time (41) the three accounting records how long Ze has been trying to send the record, which a server subtracts to recover the event time
acct-terminate-cause (49) accounting-stop why the session ended, which billing and churn reports read
nas-port-id (87) the Access-Request and the three accounting records the port text that joins one session's Access-Request to its accounting records
framed-ip-address (8) the three accounting records the address the session was given, which address-to-subscriber lookups read

Check the server before you hold Calling-Station-Id back. FreeRADIUS is not uniform about its fallbacks. Its default accounting queries are defensive: they fall back to the server clock when Event-Timestamp is absent, and they default Acct-Terminate-Cause to NAS-Reboot, so a record without either is still stored. Its IP pool queries are not. raddb/mods-config/sql/ippool/mysql/queries.conf matches AND callingstationid = '%{Calling-Station-Id}' in stop_clear, alive_update and start_update with no fallback value, so a pool served by FreeRADIUS keeps leases that never expire once attribute 31 stops arriving. Acct-Delay-Time appears in no default query at all.

Acct-Status-Type, Acct-Session-Id and the NAS identity cannot be held back, and the schema does not name them. RFC 2866 Section 5.13 counts the first two as "1 Exactly one instance of this attribute MUST be present", and its Note 1 reads "An Accounting-Request MUST contain either a NAS-IP-Address or a NAS-Identifier (or both)". Ze offers no numeric form for the same reason: a number accepts a line that suppresses a mandatory attribute and gives the operator no diagnostic either way.

Three attributes an operator may look for are deliberately absent, because no runtime value exists for them: Framed-Interface-Id, Framed-IPv6-Prefix and Delegated-IPv6-Prefix.

Configured under the l2tp config tree:

l2tp {
    auth {
        radius {
            nas-identifier ze-lns;
            nas-port-id-format "{nas-id}:{tunnel-id}.{session-id}";
            timeout 3;
            retries 3;
            acct-interval 300;
            coa-port 3799;
            server main {
                address 10.0.0.10;
                port 1812;
                shared-key radiussecret;
            }
        }
    }
}

nas-port-id-format is the template for the NAS-Port-Id attribute (RFC 2869 Section 5.17). An LNS has no physical port to number, so the operator composes one from {nas-id}, {tunnel-id} and {session-id}. Every other character is copied. All three values are known before the session has an interface, so the Access-Request and every accounting record of one session carry the same text and a billing system can join them. The text is resolved once per session, so a config reload does not move it mid-session.

Four templates are refused when the config is committed: one naming a placeholder that does not exist, one longer than 253 characters (the largest value a RADIUS attribute can carry), one using {nas-id} with no nas-identifier set, and one whose widest resolution passes 253 octets because the nas-identifier it expands is long. The last is a separate check because {nas-id} is nine characters that expand to a name the config does not bound. Unset sends no attribute.

acct-interval is the interim accounting cadence in seconds, and it has no default. RFC 2869 Section 2.1 states that a locally configured value on the NAS MUST override the value found in an Access-Accept. A leaf that is set therefore pins the cadence of every session, and a RADIUS server cannot move it. An unset leaf gives that decision to each session's Access-Accept. The value arrives in the Acct-Interim-Interval attribute (type 85), which Ze clamps to 60..3600 seconds. A session runs at 300 seconds when neither side states an interval.

The choice is which side owns the cadence. An operator who sized the interval for the NAS and the network sets the leaf. An operator who leaves it unset lets one server retime every session that server accepts.

coa-port enables the UDP Change of Authorization and Disconnect-Message listener. It has no default: leaving it unset keeps the listener off, so an upgrade cannot expose a new RADIUS endpoint unexpectedly. Port 3799 is the standard deployment choice. Ze accepts CoA/DM requests only from addresses listed under server; the authentication and accounting destination port on each server is configured separately. When no server address resolves to an IP the listener does not start at all, because there is then no dynamic authorization client to trust and the port would answer anybody.

Ze answers a request only when every check below passes. A failed check either discards the packet without a reply, which is what RFC 5176 requires where a reply would confirm the shared secret to a sender that guessed wrong, or answers a NAK carrying an Error-Cause.

Check Refused how
Source address is a configured server Discarded
Request Authenticator matches the shared secret for that source Discarded
Code is CoA-Request or Disconnect-Request Discarded
Message-Authenticator, when the request carries one, is correct Discarded
Event-Timestamp is within 5 minutes Discarded when stale, NAK 404 when absent
CoA-Request carries no Service-Type NAK 405
Every attribute is one Ze supports NAK 401
The identification attributes match exactly one session NAK 503 for none, NAK 508 for several
The requested change reached the shaper NAK 506

One of these is stricter than RFC 5176, deliberately. Section 6.3 makes the Event-Timestamp a SHOULD and lets an implementation be configurable about its absence; Ze refuses a request that omits it, because the port takes unsolicited packets that tear subscriber sessions down and without the attribute there is no replay protection.

The response echoes the request's Proxy-State and State attributes unchanged, and Ze never reads their contents.

RFC 5176 Section 3.4 makes the Message-Authenticator attribute optional, and Ze follows the RFC by default: a CoA-Request or Disconnect-Request that carries none is accepted, and one that carries a wrong value is discarded with no reply. The request still has to come from a configured Dynamic Authorization Client and still has to carry a correct Request Authenticator, both keyed on the shared secret, so an absent attribute is not an unauthenticated request.

Set require-message-authenticator true to refuse a request that carries none. Requiring the attribute is the mitigation for the Blast-RADIUS class of attack on the RADIUS/UDP MD5 authenticator, so enable it wherever the Dynamic Authorization Client can send one. FreeRADIUS radclient and the radiusd CoA originator both can. The default is permissive because a Dynamic Authorization Client that omits the attribute is conformant, and refusing it outright made Ze unable to talk to one at all.

Both authenticators are computed in the order RFC 5176 Section 3.4 fixes. The Message-Authenticator is the HMAC-MD5 over the packet with the Request Authenticator field and the Message-Authenticator value each replaced by sixteen octets of zero, and the Request Authenticator is computed afterwards, over the packet the finished Message-Authenticator is part of. A client that inverts the two is refused.

Authentication timing is controlled via the authentication container under l2tp:

l2tp {
    authentication {
        timeout 30
        reauth-interval 0
    }
}

The timeout leaf (1-3600 seconds, default 30) bounds the PPP auth phase. The reauth-interval leaf (0 or 5-86400 seconds, default 0) enables periodic re-authentication when non-zero. Values 1-4 are rejected to prevent re-auth storms.

IP address pool

The l2tp-pool plugin provides bitmap-backed IPv4 address pools. Pools are registered via the handler registry and dispatched when IPCP negotiation needs to assign an address.

Configured under the l2tp config tree:

l2tp {
    pool {
        ipv4 {
            gateway 10.100.0.1;
            start 10.100.0.2;
            end 10.100.255.254;
            dns-primary 8.8.8.8;
            dns-secondary 8.8.4.4;
        }
    }
}

Address allocation prefers RADIUS metadata when present. Framed-Pool selects a named pool for gateway and DNS values; an unknown named pool rejects the IPCP request. Framed-IP-Address then bypasses bitmap allocation and uses the selected pool's gateway and DNS with the RADIUS-assigned peer address. Framed-IP-Netmask is parsed into session metadata, but the current IPv4 IPCP response has no netmask field to apply.

Session-down events release allocated addresses back to the pool.

Traffic shaping

The l2tp-shaper plugin applies TC (traffic control) rules on pppN interfaces. Session establishment uses the configured default rate. RADIUS CoA can update the rate dynamically after the session is up.

A subscriber session is shaped in both directions, and each direction uses a different mechanism.

Direction On the pppN interface Mechanism Config leaf
Download, toward the subscriber egress the configured queueing discipline (tbf or htb) default-rate
Upload, from the subscriber ingress hook a token-bucket policer that drops traffic above the rate upload-rate

The upload direction uses a policer because the ingress hook holds no queue. A queueing discipline shapes by delaying a packet, and there is nothing to delay into on that hook, so the enforcement available is to drop what exceeds the rate. The policer attaches at tc priority 200 on the shared clsact qdisc, so interface mirroring (priority 1) and flow-export sampling (priority 100) keep working on the same interface.

A PPPoE subscriber gets the same pair of mechanisms. Both access types terminate on a pppN, so one implementation serves them both.

Configured under the l2tp config tree:

l2tp {
    shaper {
        qdisc-type tbf;            // tbf or htb
        default-rate 10mbit;       // download rate for new sessions
        upload-rate 2mbit;         // upload rate (defaults to default-rate)
    }
}

RADIUS Filter-Id can override the default shaping rate when it contains a parseable rate, otherwise Ze keeps the configured default rate. The forms 10mbit, 20mbit/5mbit, rate:10mbit and rate:20mbit/5mbit are accepted, at Access-Accept and in a CoA-Request alike. A MikroTik Mikrotik-Rate-Limit of 10M/5M is read the same way. The two-value forms set the download rate and the upload rate separately. Session-Timeout and Idle-Timeout start per-session teardown timers. Acct-Interim-Interval sets the accounting update cadence, clamped to 60..3600 seconds. It applies to a session whose deployment left acct-interval unset. RFC 2869 Section 2.1 gives a configured acct-interval precedence over it. RADIUS CoA rate updates do not tear down the session.

CQM (Call Quality Metrics)

Ze provides Firebrick-style CQM monitoring for L2TP sessions. The Observer records per-session events and per-login sample rings:

Per-session event ring -- circular buffer of state transitions (tunnel-up, session-up, session-down, echo-rtt, disconnect-requested). Disconnect events include the actor, reason text, and optional cause code. Used for the event timeline in the web UI.

Per-login CQM sample ring -- 100-second aggregated buckets with:

CQM data feeds:

Echo interval for CQM: ze.l2tp.cqm.echo-interval (env var, default derived from LCP echo configuration).

Prometheus metrics

L2TP exposes metrics under the ze_l2tp_* and ze_radius_* namespaces.

Aggregate gauges

Metric Type Description
ze_l2tp_sessions_active gauge Sessions in established state
ze_l2tp_sessions_starting gauge Sessions in negotiation
ze_l2tp_sessions_finishing gauge Sessions being torn down
ze_l2tp_tunnels_active gauge Active tunnels

Per-session counters (labels: username, session_id, interface)

Metric Type Description
ze_l2tp_session_state gauge FSM state as integer
ze_l2tp_session_uptime_seconds gauge Seconds since session creation
ze_l2tp_session_rx_bytes_total counter RX bytes on pppN interface
ze_l2tp_session_tx_bytes_total counter TX bytes on pppN interface
ze_l2tp_session_rx_packets_total counter RX packets on pppN interface
ze_l2tp_session_tx_packets_total counter TX packets on pppN interface

CQM metrics (labels: username)

Metric Type Description
ze_l2tp_lcp_echo_rtt_seconds histogram LCP echo round-trip time
ze_l2tp_lcp_echo_loss_ratio gauge Current 100s bucket echo loss ratio
ze_l2tp_bucket_state gauge CQM bucket state (established=0, negotiating=1, down=2)

PPP negotiation counters (labels: reason)

Metric Type Description
ze_ppp_ipv6cp_identifier_refusals_total counter IPv6CP negotiations refused for want of a usable interface identifier

The label set is closed and holds three values. A session or tunnel ID is deliberately not a label: a subscriber daemon carries thousands of sessions.

Reason Meaning
service-unnegotiated IPv6CP reached Opened with no negotiated peer identifier, so no IPv6 service was started for that session
event-unnegotiated The same session's address was withheld from the session-up event, for the same reason
suggestion-draw-failed Ze could not draw a valid identifier to suggest, and answered with a Configure-Reject rather than a Configure-Nak carrying a value it would refuse on receive

This counter is shared with PPPoE subscribers, which run the same PPP driver.

Reader-loop error counters

A receiver goroutine that cannot classify a socket read error retries it with a growing delay, capped at 250ms, instead of spinning. Each swallowed error is counted, so a failing socket is visible on these counters without a profiler.

Metric Type Description
ze_l2tp_listener_read_errors_total counter UDP read errors the L2TP listener's receiver goroutine swallowed and retried
ze_ppp_reader_errors_total counter Socket read errors a PPP reader goroutine swallowed and retried, labelled loop

ze_ppp_reader_errors_total carries one series per reader: loop="ra" for the IPv6 Router Advertisement listener, loop="dhcpv6" for the DHCPv6-PD listener. Both run once per PPP session with a negotiated IPv6 interface identifier, for L2TP and PPPoE subscribers alike.

The PPPoE discovery reader's own counter is listed in pppoe.md, because it is PPPoE-specific.

RADIUS metrics (labels: server)

Metric Type Description
ze_radius_up gauge Server reachability (1=up, 0=down)
ze_radius_auth_sent_total counter Access-Request packets sent
ze_radius_acct_sent_total counter Accounting-Request packets sent
ze_radius_interim_sent_total counter Interim-Update packets sent

Kernel interface stats are polled at ze.l2tp.metrics.poll-interval (default: 30s).

Web UI

The web interface at /l2tp provides session management and CQM graphing. All endpoints require authentication (session cookie or Basic Auth).

URL Method Purpose
/l2tp GET Session list with sortable columns
/l2tp/<sid> GET Session detail: state, PPP options, CQM chart, event timeline, disconnect
/l2tp/<login>/samples GET CQM buckets as columnar JSON (uPlot data shape)
/l2tp/<login>/samples.csv GET CQM buckets as CSV download
/l2tp/<login>/samples/stream GET SSE stream pushing new CQM buckets every 100s
/l2tp/<sid>/disconnect POST Disconnect session (requires reason form field; optional cause)

CQM chart

The detail page renders a client-side CQM graph using uPlot. The chart loads 24h of historical data via JSON, then appends new 100-second buckets in real time via SSE.

Chart colours are CSS custom properties (configurable via theme):

Disconnect

The disconnect button triggers a confirm dialog requiring a free-text reason (1-256 characters) and an optional Disconnect-Cause code. The POST dispatches through the CLI as clear l2tp session id <sid> reason <text> [cause <code>], so authz is enforced at the CLI layer. Read-only profiles are denied by the existing clear prefix rule.

The disconnect reason and actor are recorded in the per-session event ring for audit trail purposes.

Kernel integration

Ze uses the Linux kernel's L2TP and PPP subsystems for the data plane. Control plane (L2TP control messages, PPP negotiation) runs entirely in userspace.

Startup: the subsystem probes for l2tp_ppp and pppol2tp kernel modules via modprobe. If modules are not available, Start fails with a clear error. Set ze.l2tp.skip-kernel-probe=true for testing without kernel support.

Per-session kernel setup:

  1. Create L2TP tunnel via Generic Netlink (L2TP_CMD_TUNNEL_CREATE)
  2. Create L2TP session via Generic Netlink (L2TP_CMD_SESSION_CREATE)
  3. Create PPPoL2TP socket (binds session to L2TP kernel state)
  4. Open /dev/ppp, attach channel (PPPIOCGCHAN, PPPIOCATTCHAN)
  5. Create PPP unit (PPPIOCNEWUNIT, PPPIOCCONNECT)
  6. Kernel creates pppN interface
  7. PPP negotiation runs over the /dev/ppp channel fd

Teardown: reverse order. PPPoL2TP socket close triggers kernel session removal. Tunnel is removed after all sessions are gone.

When LCP restarts on the same transport, the PPP unit remains present. Ze removes its IPCP address using the installed local/peer pair before replacement negotiation. Linux creates the peer's connected route when the address is added and removes that route when the address is deleted. PPP does not issue a separate route deletion for this kernel-owned route. The next IPCP negotiation can reuse the address on the retained unit.

Redistribute

Ze registers l2tp as a redistribution source at subsystem Start. When a PPP NCP (IPCP or IPv6CP) completes for a session, the subsystem's RouteObserver emits a (l2tp, route-change) batch on the EventBus with the assigned peer IP as a /32 (IPv4) or /128 (IPv6) prefix. Session-down emits matching remove batches, one per address family that was up.

The redistribute orchestrator discovers L2TP as a producer at startup, subscribes to its events, and dispatches the prefixes to registered consumers (BGP) when a matching import rule is configured:

redistribute {
    destination bgp {
        import l2tp {
            family [ ipv4/unicast ipv6/unicast ];
        }
    }
}

Each peer's UPDATE carries origin=incomplete, an empty AS-path, and NEXT_HOP resolved to the peer's local session address.

Reload semantics

SIGHUP triggers Subsystem.Reload. The implementation diff-applies each knob according to this policy:

Field Reload behaviour
shared-secret Hot-apply; takes effect on new SCCRQs. Live tunnels keep the previously-negotiated state.
hello-interval Hot-apply; new tunnels use the new interval. Live tunnels keep theirs.
hello-retries Hot-apply to all reactors; affects the dead-peer deadline on the next tick.
max-tunnels Hot-apply at next admission decision.
max-sessions Hot-apply at next admission decision.
auth-method Hot-apply to new PPP sessions.
allow-no-auth Hot-apply to new PPP sessions.
authentication/timeout Hot-apply to new PPP sessions.
authentication/reauth-interval Hot-apply to new PPP sessions.
auth/local/user Apply replaces the local credential table, including removing users. Subsequent local authentication requests use it.
auth/radius Apply replaces the RADIUS client and claims the shared authentication slot; removing the block restores local authentication.
ncp/enable-ipcp Hot-apply to new PPP sessions.
ncp/enable-ipv6cp Hot-apply to new PPP sessions.
ncp/timeout Hot-apply to new PPP sessions.
enabled flip Rejected with WARN. Restart to enable/disable.
Listener endpoint change Rejected with WARN. Restart to rebind.

Rationale: the tunnel FSM carries per-tunnel state (sequence numbers, kernel fds, PPP sessions). Pushing a new hello-interval or new secret onto an existing tunnel would invalidate in-flight state. Listener changes require full driver teardown which is safer as an explicit restart.

Authentication-provider verification stages the candidate without changing credential decisions. A rejected candidate leaves the active provider and credentials unchanged. If the transaction rolls back after apply, the plugins restore their previous configuration. Neither a credential change nor an authentication-provider switch disconnects an already authenticated subscriber.

Removing RADIUS stops new accounting admissions during apply, while existing accounting sessions continue until the whole daemon reload is accepted. A web listener bind failure or candidate-publication failure after plugin commit therefore leaves those sessions available for their eventual teardown. The hub restores the previous configuration through a new transaction; accounting keeps the original session identifiers and interim loops through that compensation.

After acceptance, Ze retires the remaining records asynchronously, with one ten-second network budget for Accounting-Stop attempts, then closes the retired client. Apply does not wait for those attempts. A later accepted reload also retires an unchanged pending removal, even when only another plugin's root changes. An older completion leaves a newer tentative apply intact; if that apply rolls back, Ze honours the acceptance of the restored state. Plugin shutdown still retires its own accounting and joins its retirement workers before restart. These rules concern removal of the RADIUS provider while L2TP remains running; teardown of the L2TP subsystem itself ends its subscriber sessions.

Dead-peer detection

A HELLO is sent after hello-interval seconds of peer silence and is delivered reliably, so the peer's ZLB ACK proves the control channel is alive. Two independent clocks govern an established tunnel:

hello-retries is the dead-peer threshold: when no liveness signal arrives for hello-retries x hello-interval, the tunnel is torn down (sessions cleared, subscriber routes withdrawn, StopCCN sent, (l2tp, tunnel-down) emitted with reason keepalive-timeout). With the default hello-retries 2, a tunnel using hello-interval 5 is declared dead ~10s after the peer goes silent -- far faster than the reliable engine's ~31s retransmit exhaustion, which is the only signal when a peer (e.g. xl2tpd) dies without sending StopCCN.

Dead-peer detection is deliberately separate from the reliable-transport retransmit backoff and runs only for established tunnels, so setup (pre-established) and teardown (closed) retain the full ~31s retransmit budget for link-loss tolerance. Set hello-retries 0 to disable dead-peer detection and fall back to retransmit exhaustion alone. When hello-retries x hello-interval exceeds ~31s (e.g. the defaults 2 x 60s), retransmit exhaustion fires first and the threshold has no effect; lower hello-interval to get faster detection.

Environment variables

Remaining ze.l2tp.* env vars (not promoted to YANG config):

PPP authentication and NCP settings are now YANG config leaves under l2tp { authentication { ... } } and l2tp { ncp { ... } }.

Architecture

The subsystem uses a reactor pattern: a single reactor goroutine reads the shared UDP socket and dispatches to tunnel state machines. A separate timer goroutine handles retransmission and HELLO keepalive. PPP negotiation runs on a worker pool for blocking /dev/ppp I/O. No goroutine-per-tunnel.

UDP socket ---> Reactor goroutine ---> Tunnel FSM ---> Session FSM
                                                         |
                                                   Kernel worker
                                                   (Generic Netlink,
                                                    PPPoL2TP socket,
                                                    /dev/ppp)
                                                         |
                                                   PPP worker pool
                                                   (LCP, auth, NCP)
                                                         |
                                                   Observer (events,
                                                    CQM buckets)

Four L2TP plugins register at startup via init():