A while back I forked Net::Radius::Packet into its own module for a project called radiusNE, instead of subclassing it or patching it in place. I hadn’t looked at the diff in long enough that I’d genuinely forgotten what was in it, so this post is as much me rediscovering my own code as it is writing it up.
The actual reason for the fork
The project sits in front of a CGNAT-enabled BNG platform, managing IPoE subscriber sessions. The specific thing I needed to do was alter a session already in progress, mid-flight, without the subscriber reconnecting: flipping a captive portal on or off for a given session being the clearest example. That’s not something a normal Access-Request/Access-Accept exchange handles, because that exchange only fires when a session is being established. Changing something on a session that’s already up means originating a CoA-Request (or a Disconnect-Request, if the goal is to kick the session entirely) from the RADIUS side and sending it to the NAS.
Net::Radius::Packet can decode CoA and Disconnect packets fine, and it can respond to requests it receives, but building one to send in the first place is a different problem, and it’s where the fork actually starts.
CoA and Disconnect have their own authenticator rules
A normal Access-Request authenticator is 16 random bytes the client picks. A response authenticator is computed from the request. CoA-Request and Disconnect-Request follow neither pattern: RFC 5176 has them use the same scheme as Accounting-Request from RFC 2866. Pack the whole packet with 16 zero bytes standing in for the authenticator, MD5 that against the shared secret, then splice the digest into the authenticator field before sending. Order matters here. The digest has to be computed over the packet as if the authenticator were still zero, not over the final packet with a placeholder already swapped in.
The fork adds a dedicated pack_coa() that does exactly this, along with paired accessors for the authenticator value so it can be stashed and retrieved cleanly instead of getting tangled up with the request/response authenticator the base module already manages:
sub set_authenticator_coa {
my $authenticator_placeholder = "\x00" x 16;
}
sub get_authenticator_coa {
my ($self) = @_;
return exists $self->{AuthenticatorCOA} ? $self->{AuthenticatorCOA} : ("\x00" x 16)
}
and the packing itself:
my $authenticator_placeholder = $self->get_authenticator_coa();
my $packet_header = pack($p_hdr, $codes{$self->code}, $self->identifier,
$total_packet_length, $authenticator_placeholder);
my $packet_before_authenticator = $packet_header . $attstr;
my $authenticator = Digest::MD5::md5($packet_before_authenticator . $shared_secret);
substr($packet_before_authenticator, 4, 16, $authenticator);
return $packet_before_authenticator;
IPv6 prefix encoding, for real this time
The upstream module’s own documentation is honest about this one: ipv6addr, date, and ifid are, in its own words, simply mapped to other types until correct encoding is implemented. ipv6prefix and tagged-ipv6prefix aren’t handled upstream at all. For dual-stack IPoE sessions on that platform that’s not a cosmetic gap, since Framed-IPv6-Prefix and similar attributes need to actually round-trip correctly for a session to come up with working IPv6, not just IPv4.
The fork adds real pack and unpack for both, using inet_pton/inet_ntop against the RFC-defined wire format (a reserved byte, a prefix length byte, then 16 bytes of address):
"ipv6prefix" => sub {
my $prefix = $_[0];
my ($ipv6, $length) = split('/', $prefix);
my $packed_ipv6 = inet_pton(AF_INET6, $ipv6);
if (length($packed_ipv6) != 16) {
warn "Invalid IPv6 address length";
}
return pack("C C a16", 0, $length, $packed_ipv6);
}
with the corresponding unpack turning the wire bytes back into a human-readable address/prefixlen string. Small in isolation, but this is the piece that actually fixed dual-stack sessions rather than just working around them.
Tagged prefixes, and a bit of debugging courtesy
The plain prefix handling above covers ipv6prefix, but there’s a tagged variant too, tagged-ipv6prefix, used where an attribute needs to carry a tag byte ahead of the usual reserved/length/address fields (the same tagging convention RFC 2868 uses elsewhere). The fork adds that as well:
"tagged-ipv6prefix" => sub {
my ($tagged) = @_; # "5,2001:db8::/48"
my ($tag, $prefix) = split /,/, $tagged, 2;
my ($addr, $plen) = split '/', $prefix;
my $packed = inet_pton(AF_INET6, $addr)
or die "inet_pton failed for $addr";
return pack 'C C C a16', $tag, 0, $plen, $packed;
}
with a matching unpack on the receive side, so tagged prefixes round-trip the same way plain ones do.
The other change is smaller but has saved more debugging time in practice. Stock behavior when the VSA-packing loop hits an attribute type it doesn’t recognize is to just skip it, silently. On a live NAS exchange, a VSA that quietly failed to make it into the outgoing packet is exactly the kind of thing that burns an afternoon before you think to suspect it. The fork adds a warning at that exact point instead:
unless ( defined $type && exists $vsapacker{$type} ) {
carp "Unknown VSA $vid/$attr – dropped" if $self->{unknown_entries};
next;
}
Same fallback behavior, but now it says so.
Making CoA-NAKs readable
RFC 5176 defines a set of numeric Error-Cause values a NAS can return in a NAK, and out of the box you just get the number. The fork adds an errorCodes() lookup covering the RFC 5176 causes, from residual session cleanup (201) through the various fatal request errors (401 through 407) to the proxy and resource ones (501 through 508), each with both the short cause and the fuller RFC description. Debugging a rejected CoA against that box goes from grepping the RFC for what error 403 means to just reading it off the response.
Why a fork instead of a patch
All of this could theoretically have been contributed upstream or maintained as a patch series, but a CoA workflow specific to that platform with a fairly specific captive-portal use case is not something I’d expect the general module to want to carry, and patch series rot the moment the upstream module moves out from under them. A standalone copy, called directly instead of through the usual namespace, keeps it simple: what’s in the file is what’s running, no patch application step, no version skew to track.
The tradeoff is exactly what you’d expect: any future upstream fix or improvement to Net::Radius::Packet has to be manually ported over if I want it, since there’s no dependency link back to the original at all. For a fork this targeted, that’s been a fair trade so far.

Leave a Reply