Skip to content

Commit aca3b5f

Browse files
committed
l2tp: implement traffic shaping plugin and RADIUS CoA/DM listener (spec-l2tp-8c-shaper)
New l2tp-shaper plugin subscribes to EventBus session lifecycle events and programs TC qdiscs (TBF/HTB) on pppN interfaces via the existing traffic.Backend abstraction. CoA/DM listener (RFC 5176) in the RADIUS plugin receives rate-change and disconnect requests from RADIUS servers. Components: - l2tp/events: SessionUp and SessionRateChange typed EventBus events - l2tp/reactor: emit SessionUp on ppp.EventSessionUp, store pppInterface - radius/dict: CoA/DM packet codes (40-45), Error-Cause attribute (101) - radius/packet: VerifyCoARequestAuth for RFC 5176 request validation - l2tpshaper: plugin with config parsing, session state, TC application - l2tpauthradius/coa: UDP listener with auth verification, session matching by Acct-Session-Id prefix or User-Name+NAS-Port, source address filtering from configured RADIUS servers - traffic: export ParseRateBps for cross-package rate parsing - rfc/short/rfc5176.md: RFC summary for Dynamic Authorization
1 parent 0b57ebc commit aca3b5f

25 files changed

Lines changed: 2303 additions & 9 deletions

internal/component/l2tp/events/events.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,39 @@ type SessionDownPayload struct {
4949
// plugin to release allocated IP addresses.
5050
var SessionDown = events.Register[*SessionDownPayload](Namespace, SessionDownEvent)
5151

52+
// SessionUpEvent is the event type string for session-up notifications.
53+
const SessionUpEvent = "session-up"
54+
55+
// SessionUpPayload carries session identity and the pppN interface name.
56+
// Emitted by the reactor when PPP LCP, authentication, and all enabled
57+
// NCPs complete successfully (ppp.EventSessionUp).
58+
type SessionUpPayload struct {
59+
TunnelID uint16
60+
SessionID uint16
61+
Interface string
62+
}
63+
64+
// SessionUp is the typed handle for (l2tp, session-up). Consumed by
65+
// the shaper plugin to apply TC rules and by stats plugins.
66+
var SessionUp = events.Register[*SessionUpPayload](Namespace, SessionUpEvent)
67+
68+
// SessionRateChangeEvent is the event type string for rate-change notifications.
69+
const SessionRateChangeEvent = "session-rate-change"
70+
71+
// SessionRateChangePayload carries updated bandwidth for a session.
72+
// Emitted by the CoA handler in the RADIUS plugin when a RADIUS server
73+
// sends a CoA-Request with bandwidth attributes.
74+
type SessionRateChangePayload struct {
75+
TunnelID uint16
76+
SessionID uint16
77+
DownloadRate uint64 // bits per second
78+
UploadRate uint64 // bits per second
79+
}
80+
81+
// SessionRateChange is the typed handle for (l2tp, session-rate-change).
82+
// Consumed by the shaper plugin to update TC rules on the session's pppN.
83+
var SessionRateChange = events.Register[*SessionRateChangePayload](Namespace, SessionRateChangeEvent)
84+
5285
// SessionIPAssignedEvent is the event type string for session-ip-assigned notifications.
5386
const SessionIPAssignedEvent = "session-ip-assigned"
5487

internal/component/l2tp/events/events_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,15 @@ func TestL2TP_RegisteredAsProducer(t *testing.T) {
2828
require.True(t, slices.Contains(prods, ProtocolID),
2929
"l2tp ProtocolID must appear in Producers()")
3030
}
31+
32+
// VALIDATES: AC-1 prerequisite -- SessionUp typed handle exists.
33+
func TestSessionUpHandle_Registered(t *testing.T) {
34+
require.Equal(t, "l2tp", SessionUp.Namespace())
35+
require.Equal(t, SessionUpEvent, SessionUp.EventType())
36+
}
37+
38+
// VALIDATES: SessionRateChange typed handle exists.
39+
func TestSessionRateChangeHandle_Registered(t *testing.T) {
40+
require.Equal(t, "l2tp", SessionRateChange.Namespace())
41+
require.Equal(t, SessionRateChangeEvent, SessionRateChange.EventType())
42+
}

internal/component/l2tp/reactor.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,15 @@ func (r *L2TPReactor) handleKernelSuccess(ksucc kernelSetupSucceeded) {
845845
ProxyLCPLastRecv: ksucc.proxyLastRecvLCPConfReq,
846846
}
847847

848+
ifaceName := fmt.Sprintf("ppp%d", ksucc.fds.unitNum)
849+
r.tunnelsMu.Lock()
850+
if tunnel, ok := r.tunnelsByLocalID[ksucc.localTID]; ok {
851+
if sess := tunnel.lookupSession(ksucc.localSID); sess != nil {
852+
sess.pppInterface = ifaceName
853+
}
854+
}
855+
r.tunnelsMu.Unlock()
856+
848857
select {
849858
case r.pppDriver.SessionsIn() <- start:
850859
case <-r.stop:
@@ -879,7 +888,10 @@ func (r *L2TPReactor) handlePPPEvent(ev ppp.Event) {
879888
tid, sid, reason = e.TunnelID, e.SessionID, e.Reason
880889
case ppp.EventSessionRejected:
881890
tid, sid, reason = e.TunnelID, e.SessionID, e.Reason
882-
case ppp.EventLCPUp, ppp.EventLCPDown, ppp.EventSessionUp:
891+
case ppp.EventLCPUp, ppp.EventLCPDown:
892+
return
893+
case ppp.EventSessionUp:
894+
r.handleSessionUp(e)
883895
return
884896
}
885897
if tid == 0 && sid == 0 {
@@ -977,6 +989,33 @@ func (r *L2TPReactor) handleSessionIPAssigned(ev ppp.EventSessionIPAssigned) {
977989
}
978990
}
979991

992+
// handleSessionUp emits the (l2tp, session-up) EventBus event when a
993+
// PPP session completes LCP, auth, and all NCPs. The shaper plugin
994+
// subscribes to this event to apply TC rules on the pppN interface.
995+
func (r *L2TPReactor) handleSessionUp(ev ppp.EventSessionUp) {
996+
if r.eventBus == nil {
997+
return
998+
}
999+
var ifaceName string
1000+
r.tunnelsMu.Lock()
1001+
if tunnel, ok := r.tunnelsByLocalID[ev.TunnelID]; ok {
1002+
if sess := tunnel.lookupSession(ev.SessionID); sess != nil {
1003+
ifaceName = sess.pppInterface
1004+
}
1005+
}
1006+
r.tunnelsMu.Unlock()
1007+
if ifaceName == "" {
1008+
return
1009+
}
1010+
if _, err := l2tpevents.SessionUp.Emit(r.eventBus, &l2tpevents.SessionUpPayload{
1011+
TunnelID: ev.TunnelID,
1012+
SessionID: ev.SessionID,
1013+
Interface: ifaceName,
1014+
}); err != nil {
1015+
r.logger.Warn("l2tp: session-up emit failed", "error", err)
1016+
}
1017+
}
1018+
9801019
// handleKernelError processes a setup failure reported by the kernel
9811020
// worker. Grabs tunnelsMu, looks up the session, and sends a CDN to
9821021
// the peer if the session still exists.

internal/component/l2tp/session.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ type L2TPSession struct {
115115
// (LNS side, true) or handleOCCN (LAC side, false). Used by the
116116
// kernel worker to set L2TP_ATTR_LNS_MODE.
117117
lnsMode bool
118+
119+
// pppInterface is the kernel pppN interface name (e.g. "ppp0").
120+
// Set by handleKernelSuccess when the PPP session is started.
121+
// Used by SessionUp EventBus event so the shaper can apply TC.
122+
pppInterface string
118123
}
119124

120125
// State returns the session's current FSM state.

internal/component/plugin/all/all.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/component/radius/dict.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
// Design: docs/research/l2tpv2-ze-integration.md -- RADIUS attribute dictionary
2+
// Related: packet.go -- packet encode/decode consuming these codes
3+
// Related: client.go -- client transport using packet codes
4+
// Related: attr.go -- attribute encode/decode helpers
25

36
package radius
47

5-
// RADIUS packet codes (RFC 2865 Section 3, RFC 2866 Section 3).
8+
// RADIUS packet codes (RFC 2865 Section 3, RFC 2866 Section 3, RFC 5176 Section 3).
69
const (
710
CodeAccessRequest = 1
811
CodeAccessAccept = 2
912
CodeAccessReject = 3
1013
CodeAccountingReq = 4
1114
CodeAccountingResp = 5
1215
CodeAccessChallenge = 11
16+
17+
// RFC 5176 Section 3: Dynamic Authorization Extensions (CoA/DM).
18+
CodeDisconnectRequest = 40
19+
CodeDisconnectACK = 41
20+
CodeDisconnectNAK = 42
21+
CodeCoARequest = 43
22+
CodeCoAACK = 44
23+
CodeCoANAK = 45
1324
)
1425

1526
// RADIUS attribute type codes (RFC 2865 Section 5).
@@ -40,6 +51,7 @@ const (
4051
AttrCHAPChallenge = 60
4152
AttrNASPortType = 61
4253
AttrFramedPool = 88
54+
AttrErrorCause = 101 // RFC 5176 Section 3.6
4355
AttrVendorSpecific = 26
4456
)
4557

@@ -77,6 +89,19 @@ const (
7789
NASPortTypeVirtual = 5
7890
)
7991

92+
// Error-Cause values (RFC 5176 Section 3.6).
93+
const (
94+
ErrorCauseResidualSession = 201
95+
ErrorCauseInvalidEAPPacket = 202
96+
ErrorCauseUnsupportedAttribute = 401
97+
ErrorCauseMissingAttribute = 402
98+
ErrorCauseNASIdentification = 403
99+
ErrorCauseInvalidRequest = 404
100+
ErrorCauseUnsupportedService = 405
101+
ErrorCauseUnsupportedExtension = 406
102+
ErrorCauseSessionNotFound = 503
103+
)
104+
80105
// Wire constants.
81106
const (
82107
HeaderLen = 20 // Code(1) + ID(1) + Length(2) + Authenticator(16)

internal/component/radius/packet.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,25 @@ func VerifyResponseAuth(response []byte, requestAuth [AuthenticatorLen]byte, sec
172172
return subtle.ConstantTimeCompare(response[4:4+AuthenticatorLen], expected[:]) == 1
173173
}
174174

175+
// VerifyCoARequestAuth checks the authenticator of a CoA-Request or
176+
// Disconnect-Request. RFC 5176 Section 3.5: same formula as
177+
// Accounting-Request (MD5 over Code+ID+Length+16-zero-octets+Attrs+Secret).
178+
// Uses constant-time comparison.
179+
func VerifyCoARequestAuth(data, secret []byte) bool {
180+
if len(data) < MinPacketLen {
181+
return false
182+
}
183+
pktLen := int(binary.BigEndian.Uint16(data[2:4]))
184+
if pktLen < MinPacketLen || pktLen > len(data) {
185+
return false
186+
}
187+
expected := AccountingRequestAuth(data, pktLen, secret)
188+
return subtle.ConstantTimeCompare(data[4:4+AuthenticatorLen], expected[:]) == 1
189+
}
190+
175191
// AccountingRequestAuth computes the authenticator for an Accounting-Request.
176192
// RFC 2866 Section 3: MD5(Code+ID+Length+16zero+Attributes+Secret).
193+
// RFC 5176 Section 3.5: same formula for CoA-Request and Disconnect-Request.
177194
func AccountingRequestAuth(buf []byte, length int, secret []byte) [AuthenticatorLen]byte {
178195
h := md5.New() //nolint:gosec // RFC 2866 mandates MD5
179196
h.Write(buf[:4])

internal/component/radius/packet_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,105 @@ func TestAccountingRequestAuth(t *testing.T) {
252252
}
253253
}
254254

255+
// VALIDATES: AC-3/AC-4 -- CoA-Request (code 43) round-trips through encode/decode.
256+
func TestCoARequestRoundTrip(t *testing.T) {
257+
pkt := &Packet{
258+
Code: CodeCoARequest,
259+
Identifier: 10,
260+
Attrs: []Attr{
261+
{Type: AttrAcctSessionID, Value: AttrString("sess-001")},
262+
{Type: AttrFilterID, Value: AttrString("10mbit")},
263+
},
264+
}
265+
266+
buf := make([]byte, MaxPacketLen)
267+
n, err := pkt.EncodeTo(buf, 0)
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
272+
// Set proper CoA authenticator.
273+
secret := []byte("coa-secret")
274+
auth := AccountingRequestAuth(buf, n, secret)
275+
copy(buf[4:4+AuthenticatorLen], auth[:])
276+
277+
decoded, err := Decode(buf[:n])
278+
if err != nil {
279+
t.Fatal(err)
280+
}
281+
if decoded.Code != CodeCoARequest {
282+
t.Errorf("code: got %d, want %d", decoded.Code, CodeCoARequest)
283+
}
284+
sessID := decoded.FindAttr(AttrAcctSessionID)
285+
if string(sessID) != "sess-001" {
286+
t.Errorf("Acct-Session-Id: got %q, want %q", sessID, "sess-001")
287+
}
288+
}
289+
290+
// VALIDATES: AC-6/AC-7 -- Disconnect-Request (code 40) round-trips.
291+
func TestDisconnectRequestRoundTrip(t *testing.T) {
292+
pkt := &Packet{
293+
Code: CodeDisconnectRequest,
294+
Identifier: 20,
295+
Attrs: []Attr{
296+
{Type: AttrAcctSessionID, Value: AttrString("sess-002")},
297+
},
298+
}
299+
300+
buf := make([]byte, MaxPacketLen)
301+
n, err := pkt.EncodeTo(buf, 0)
302+
if err != nil {
303+
t.Fatal(err)
304+
}
305+
306+
decoded, err := Decode(buf[:n])
307+
if err != nil {
308+
t.Fatal(err)
309+
}
310+
if decoded.Code != CodeDisconnectRequest {
311+
t.Errorf("code: got %d, want %d", decoded.Code, CodeDisconnectRequest)
312+
}
313+
}
314+
315+
// VALIDATES: AC-3 -- CoA request authenticator verification.
316+
func TestVerifyCoARequestAuth(t *testing.T) {
317+
secret := []byte("coa-test-secret")
318+
pkt := &Packet{
319+
Code: CodeCoARequest,
320+
Identifier: 5,
321+
Attrs: []Attr{
322+
{Type: AttrAcctSessionID, Value: AttrString("sess-100")},
323+
},
324+
}
325+
326+
buf := make([]byte, MaxPacketLen)
327+
n, err := pkt.EncodeTo(buf, 0)
328+
if err != nil {
329+
t.Fatal(err)
330+
}
331+
332+
// Set correct authenticator.
333+
auth := AccountingRequestAuth(buf, n, secret)
334+
copy(buf[4:4+AuthenticatorLen], auth[:])
335+
336+
if !VerifyCoARequestAuth(buf[:n], secret) {
337+
t.Error("valid CoA auth should verify")
338+
}
339+
340+
// Corrupt one byte.
341+
buf[4]++
342+
if VerifyCoARequestAuth(buf[:n], secret) {
343+
t.Error("corrupted CoA auth should fail verification")
344+
}
345+
}
346+
347+
// VALIDATES: AC-4 -- invalid authenticator on short packet.
348+
func TestVerifyCoARequestAuthTooShort(t *testing.T) {
349+
if VerifyCoARequestAuth(make([]byte, 10), []byte("secret")) {
350+
t.Error("short packet should fail verification")
351+
}
352+
}
353+
255354
func TestEncodeAtOffset(t *testing.T) {
256355
pkt := &Packet{
257356
Code: CodeAccessRequest,

internal/component/traffic/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,15 @@ func parseTrafficClass(name string, m map[string]any) (TrafficClass, error) {
8686
tc := TrafficClass{Name: name}
8787

8888
if rateStr, ok := m["rate"].(string); ok {
89-
rate, err := parseRateBps(rateStr)
89+
rate, err := ParseRateBps(rateStr)
9090
if err != nil {
9191
return TrafficClass{}, fmt.Errorf("rate: %w", err)
9292
}
9393
tc.Rate = rate
9494
}
9595

9696
if ceilStr, ok := m["ceil"].(string); ok {
97-
ceil, err := parseRateBps(ceilStr)
97+
ceil, err := ParseRateBps(ceilStr)
9898
if err != nil {
9999
return TrafficClass{}, fmt.Errorf("ceil: %w", err)
100100
}
@@ -147,7 +147,7 @@ var rateSuffixes = []struct {
147147
{"bps", 8},
148148
}
149149

150-
func parseRateBps(v string) (uint64, error) {
150+
func ParseRateBps(v string) (uint64, error) {
151151
for _, rs := range rateSuffixes {
152152
if strings.HasSuffix(v, rs.suffix) {
153153
numStr := v[:len(v)-len(rs.suffix)]

internal/component/traffic/config_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,19 @@ func TestParseTrafficRateFormats(t *testing.T) {
6767
}
6868
for _, tt := range tests {
6969
t.Run(tt.name, func(t *testing.T) {
70-
got, err := parseRateBps(tt.rate)
70+
got, err := ParseRateBps(tt.rate)
7171
if err != nil {
72-
t.Fatalf("parseRateBps(%q): %v", tt.rate, err)
72+
t.Fatalf("ParseRateBps(%q): %v", tt.rate, err)
7373
}
7474
if got != tt.want {
75-
t.Errorf("parseRateBps(%q) = %d, want %d", tt.rate, got, tt.want)
75+
t.Errorf("ParseRateBps(%q) = %d, want %d", tt.rate, got, tt.want)
7676
}
7777
})
7878
}
7979
}
8080

8181
func TestParseTrafficInvalidRate(t *testing.T) {
82-
_, err := parseRateBps("notarate")
82+
_, err := ParseRateBps("notarate")
8383
if err == nil {
8484
t.Fatal("expected error for invalid rate")
8585
}

0 commit comments

Comments
 (0)