Lunar - Kafka
Lunar can mirror RADIUS authentication and accounting activity onto Kafka for downstream consumers (billing, analytics, fraud, audit, ...). This is entirely optional and completely separate from answering the NAS: events are delivered best-effort and never delay the RADIUS response to the NAS.
This document is for developers who consume these Kafka events. It describes what each event contains and the exact JSON format of the payload, including where the data comes from and how to interpret RADIUS attributes.
Where the data comes from
Every event ultimately originates from a NAS, the Network Access Server the subscriber connects through. In a broadband network this is typically a BNG / BRAS (Cisco, Juniper, Nokia, NetElastic, ...). On Wi-Fi it is a wireless controller. The NAS speaks RADIUS: it sends binary UDP packets to ask "may this subscriber connect?" (authentication) and to report "here is what this subscriber used" (accounting).
Those packets are in RADIUS wire format (RFC 2865 for authentication, RFC 2866 for accounting), a compact binary encoding, not something you would read directly. Lunar (our RADIUS proxy) receives them and parses them using RADIUS dictionaries, the definitions that map each attribute's numeric code to a human name and a data type. It then re-serializes the result as the readable JSON you receive in these events. So by the time an event reaches you the binary packet has already been decoded into named fields with typed values.
RADIUS in a nutshell (for consumers new to RADIUS)
You do not need to know the RADIUS wire protocol to consume these events. Lunar has already parsed the raw UDP packets into readable JSON. A few concepts make the payloads obvious:
A RADIUS message is a packet. Each packet has a short code naming its kind (access-request, access-accept, access-reject, accounting-request, ...) and carries a flat list of attributes.
An attribute is a named field, e.g. user-name, nas-ip-address, acct-session-id. On the wire each attribute is just a number. The RADIUS dictionary resolves that number to the human name and tells lunar its data type. Attributes you consume are therefore keyed by name. Lunar always emits that name in lower case (user-name, nas-ip-address). Match keys case-insensitively.
The same attribute may appear more than once in one packet. That is how RADIUS represents a multi-valued field (it has no list type). So every attribute's decoded values are given as an array, in the order they appeared, even when there is only one value.
Authentication is a request/response exchange: the NAS sends an access-request. The platform answers access-accept (allowed) or access-reject (denied), optionally with reply attributes (e.g. a session class or bandwidth limits). The authentication event carries both sides so you see the question and the answer together.
Accounting is one-way reporting: the NAS sends an accounting-request whose acct-status-type says start (session began), interim-update (periodic usage) or stop (session ended). A subscriber's session is tracked by its acct-session-id. Usage counters such as acct-input-octets / acct-output-octets and acct-session-time arrive on interim-update and stop.
Standard vs vendor-specific attributes
Attributes fall into two groups, and this matters a lot when you consume them:
Standard attributes are defined by the RADIUS RFCs, chiefly RFC 2865 (authentication) and RFC 2866 (accounting). Their names and meanings are the same regardless of which vendor's NAS sent them: user-name, nas-ip-address, calling-station-id, acct-status-type, acct-session-id, acct-input-octets, etc. Prefer these whenever a piece of information you need is available as a standard attribute.
Vendor-specific attributes (VSAs) carry data the RFCs do not define. Every vendor (Cisco, Juniper, Nokia, NetElastic, ...) publishes its own set, so different NAS devices express the same idea with different attributes. For example the subscriber's assigned data rate, remaining quota or service profile may be a Cisco AV-Pair on one network and a Juniper or Nokia attribute on another.
Practical guidance when writing a consumer:
Decide what information you need (username, session id, MAC, usage, rate limit, ...) and first look for it as a standard RFC 2865 / RFC 2866 attribute. That path works across all vendors.
Do not assume a different vendor's device uses the same attributes as the one you tested against. If you rely on a vendor attribute, gate it on the vendor/device it comes from.
Normalize at consumption time: you know what you are looking for, so map the various vendor attributes onto your own internal fields and work with those downstream. That keeps the rest of your system independent of which NAS produced the event.
The attribute names, types and value formats are detailed under "Attribute value format".
Enabling
Event shipping is optional and configured on the lunar proxy. Two independent streams can be enabled, each with its own Kafka topic:
accounting topic: RADIUS accounting events.
authentication topic: RADIUS authentication events.
Either, both or neither may be enabled. A stream with no topic ships nothing. Ask your platform operator which topics carry which stream.
The two event streams
The two streams become "complete" at different moments, so they are emitted at different times:
- Accounting events
Emitted as soon as an accounting request arrives from the NAS. An accounting record is self-contained, so there is nothing to wait for.
- Authentication events
Emitted once the platform has decided the request, because the event pairs the request with that decision. Only requests that produced a valid decision are shipped. A request that could not be answered ships no event.
Both streams are best-effort: under extreme load an event may be dropped rather than delay answering the NAS. Do not treat the stream as a guaranteed, gap-free ledger. Use it for analytics, billing feeds, audit and similar, and reconcile against session ids where completeness matters.
Common envelope
Every event (accounting or authentication) is a single JSON object with the same outer envelope. Only the event name and the data payload differ between the two streams:
{
"timestamp": 1751932800123,
"event": "radius-accounting",
"source": {
"hostname": "radius-proxy-1.domain.com",
"server-id": "your-lunar-server-id"
},
"metadata": {
"virtual_id": "...",
"radius_client_id": "...",
"radius_client_ip": "...",
"radius_nas_id": "...",
"radius_nas_ip": "..."
},
"data": { ... }
}
Envelope fields:
- timestamp
Unix time in milliseconds at the moment lunar enqueued the event.
- event
The stream name: radius-accounting or radius-authentication.
- source
Which lunar proxy produced the event: its hostname and its server-id (the identifier assigned to your lunar instance). Use this to tell apart events when several lunar proxies feed the same topic.
- metadata
Identity fields describing where the packet came from (see below). Omitted entirely when empty. In practice always present for RADIUS events.
- data
The stream-specific payload, embedded as a JSON object (see each stream below).
metadata fields (identical for both streams):
- virtual_id
The virtual RADIUS server inside lunar that received the packet. A single lunar proxy can host several virtual RADIUS servers (for example one per customer or region). This is the one that handled this event.
- radius_client_id / radius_client_ip
The RADIUS client that sent the packet to lunar: the name configured for it on our platform (typically the BNG/BRAS name) and the source IP the packet arrived from.
- radius_nas_id / radius_nas_ip
The NAS the subscriber actually connected through, taken from the request's nas-identifier / nas-ip-address attributes (empty when the request carried neither).
- auth (authentication events only)
The authentication method(s) the request used, as a comma-separated list, e.g. "pap", "eap" or "pap,eap" when more than one is present. Detected from which credential attributes the request carried: pap (user-password), chap (chap-password), mschap (ms-chap-response), mschapv2 (ms-chap2-response) and eap (eap-message). Absent when no known method was recognized. Use this to know how the subscriber authenticated without inspecting credential attributes yourself (which are redacted, see below). Accounting events never carry auth.
Why both a client and a NAS identity? Usually they are the same device: the BNG both terminates the subscriber and talks RADIUS to lunar, so radius_client_* and radius_nas_* match. But the RADIUS client can also be another RADIUS proxy that forwards traffic on behalf of a NAS sitting behind it. In that case radius_client_* identifies the proxy that connected to lunar, while radius_nas_* identifies the real NAS further downstream. Key your subscriber/session logic off the NAS identity, and use the client identity to know which device delivered the packet to us.
Partition key
Each event's Kafka partition key is chosen from the request attributes, in this order of preference, using the first attribute present (and its first value when multi-valued):
user-name
calling-station-id
class
acct-session-id
This keeps all records for one subscriber / station / session on the same partition, so a consumer sees them in order. When none of those attributes is present the key is empty (keyless) and the event is spread across partitions by Kafka's default partitioner.
Accounting event
event: radius-accounting
data: the decoded accounting packet (see "Attribute value format").
Example:
{
"timestamp": 1751932800123,
"event": "radius-accounting",
"source": {
"hostname": "radius-proxy-1.domain.com",
"server-id": "your-lunar-server-id"
},
"metadata": {
"virtual_id": "1a2b3c",
"radius_client_id": "bras-01",
"radius_client_ip": "203.0.113.10",
"radius_nas_id": "bras-01",
"radius_nas_ip": "203.0.113.10"
},
"data": {
"code": "accounting-request",
"attributes": {
"user-name": {"values": ["alice"], "type": "text", "code": "1"},
"acct-status-type": {"values": ["interim-update"],
"type": "integer", "code": "40"},
"acct-session-id": {"values": ["5f3c..."], "type": "text",
"code": "44"},
"acct-input-octets": {"values": [15315], "type": "integer",
"code": "42"},
"acct-output-octets": {"values": [5877], "type": "integer",
"code": "43"}
}
}
}
Note how acct-input-octets / acct-output-octets are bare numbers (15315, 5877) while the acct-status-type enum is a string ("interim-update"). See "Value types" for the rule, and "Accounting: sessions, counters and event types" for what these counters mean.
Authentication event
event: radius-authentication
data: an object pairing the request with the platform's response:
{"request": <request packet JSON>, "response": <platform reply JSON>}request is the decoded access-request from the NAS. response is the platform's authentication decision for it (an access-accept or access-reject packet, with any reply attributes). Both use the same attribute format described below.
Example:
{
"timestamp": 1751932800456,
"event": "radius-authentication",
"source": {
"hostname": "radius-proxy-1.domain.com",
"server-id": "your-lunar-server-id"
},
"metadata": {
"virtual_id": "1a2b3c",
"radius_client_id": "bras-01",
"radius_client_ip": "203.0.113.10",
"radius_nas_id": "bras-01",
"radius_nas_ip": "203.0.113.10",
"auth": "pap"
},
"data": {
"request": {
"code": "access-request",
"attributes": {
"user-name": {"values": ["alice"], "type": "text", "code": "1"},
"user-password": {"values": ["--OBFUSCATED--"], "type": "text",
"code": "2"}
}
},
"response": {
"code": "access-accept",
"attributes": {
"class": {"values": ["0x6e733a..."], "type": "octets",
"code": "25"}
}
}
}
}
Attribute value format
This section explains how to read the decoded packet in data.
Packet shape
A decoded packet (the accounting data, or data.request / data.response for authentication) is:
{
"code": "access-request", // the kind of packet (see below)
"attributes": { ... } // the named fields, described next
}
code: the packet kind. For requests you will see access-request (auth) and accounting-request (acct). For the platform's decision in an auth event you will see access-accept or access-reject (and occasionally access-challenge).
Attributes
attributes is an object keyed by the attribute's dictionary name, emitted in lower case (user-name, nas-ip-address). Each entry has the same three fields:
"user-name": { "values": ["alice"], "type": "text", "code": "1" }
values: always an array. RADIUS does not have a separate "list" type. Instead, when an attribute needs to carry several values, the NAS simply includes that same attribute multiple times in one packet, and the order in which they appear is significant (RFC 2865 s4.1). Lunar therefore keeps every occurrence of an attribute together, in wire order, as this array, so no value is lost and their order is preserved. Common repeated attributes are class, vendor cisco-avpair and reply-message. Most attributes appear only once, so the array usually has a single element: read values[0] for those, and iterate when you expect (or want to be safe about) repeats.
type: the attribute's data type (see "Value types" below), telling you how to read each string in values.
code: the attribute's numeric identity on the wire (a dotted path for vendor attributes, e.g. 26.24757.1.1). You normally key off the name and can ignore code. It is what lets an echo reproduce the exact wire bytes.
A few attributes are "tagged" (RFC 2868, used to group related tunnel attributes). When present the tag is appended to the key as name:tag, e.g. "tunnel-private-group-id:1". If you are matching on a name, strip anything after the :. Untagged attributes never carry a suffix.
Value types
type tells you how to read each entry in values. Integer-family values are emitted as real JSON numbers. Everything else is a JSON string (this typing is consistent, the same attribute is always the same JSON type). The common types:
text: a UTF-8 string, given as-is (e.g. "alice"). A JSON string.
integer / short / byte / integer64: a whole number, emitted as a real JSON number (e.g. 3600, not "3600"). Read it as a number, not a string. Two things to know:
integer / short / byte are unsigned 32/16/8-bit. integer64 is a 64-bit counter (used where a 32-bit counter would wrap, see "Accounting: sessions, counters and event types").
Some integer attributes are enums whose numbers have named meanings. Where the name is known lunar emits the name as a JSON string instead of the number, in lower case (e.g. acct-status-type is "start", not 1). So an enum attribute reads as a string and a plain counter reads as a number. Match enum values case-insensitively. If you ever see a bare number where you expected an enum name, the dictionary simply has no name for that value, use the number as-is.
ipaddr / ipv6addr: an IP address in the usual text form ("203.0.113.10", "2001:db8::1"). A JSON string.
ipv4prefix / ipv6prefix: an address with a prefix length ("2001:db8::/48"). A JSON string.
ether: a MAC address ("00:11:22:33:44:55"). A JSON string.
time: a timestamp, emitted as an ISO-8601 UTC string ("2025-01-01T00:00:00Z"), not a number.
string and octets: opaque binary. The value is a JSON string of 0x followed by hex: two hex digits per byte, so "0x00000041" is the four bytes 00 00 00 41. Lunar does not try to interpret these bytes. It hands them to you verbatim as hex. class is the common example: treat it as an opaque token to store and echo back unchanged, not something to parse.
Unknown / vendor attributes
Lunar forwards attributes that are not in its dictionary so nothing is lost, but it cannot give them a friendly name or a data type:
they are keyed by their code with an attr- prefix. For a plain standard attribute that is just the number, e.g. "attr-196" (attribute 196 that the loaded dictionary has no definition for). For a vendor attribute it is the dotted vendor path, e.g. "attr-26.24757.1.1" (26 is the RADIUS "Vendor-Specific" code, 24757 the vendor's IANA number, then the vendor's attribute number).
because lunar has no type for them, their type is always octets and their values are opaque 0x.. hex, the raw attribute bytes. For example:
"attr-196": {"values": ["0x00000041"], "type": "octets", "code": "196"}is the four raw bytes 00 00 00 41 of attribute 196.
An attr- key therefore always means "lunar did not recognize this attribute": the very same attribute would appear under a friendly name with a real type (and, for an enum, a decoded name) if it were added to the RADIUS dictionary. If your consumer needs one of these decoded, either interpret the hex yourself from the vendor's/standard's definition, or ask your platform operator to add that attribute to the dictionary so future events carry it by name. This is also a reminder that vendor attributes vary by device: build your logic around the standard RFC 2865 / RFC 2866 attributes wherever possible, and treat vendor-specific ones per device.
Credential redaction
Authentication events never carry usable credentials. Before an authentication event is produced, lunar redacts the request:
Password attributes keep their key but their value becomes the literal string --OBFUSCATED--. This applies to any attribute the RADIUS dictionary marks as obfuscated, most commonly user-password. You can still see that a password was supplied (the key is present), never its value.
Challenge and response attributes are removed entirely. They are not in the event at all. This covers CHAP (chap-password, chap-challenge), MS-CHAP / MS-CHAPv2 (ms-chap-response, ms-chap-challenge, ms-chap2-response, ...) and EAP (eap-message, eap-key-name), among other credential-bearing attributes.
To know which method was used, read the auth metadata field (see "metadata fields") rather than looking for these attributes. By design they are not present. Redaction affects only the event. The request lunar forwards to the platform for the actual authentication is untouched.
Accounting: sessions, counters and event types
Accounting events describe a subscriber session on the NAS/BNG over its whole lifetime. A few RADIUS accounting rules make the numbers in an radius-accounting event usable.
Event types (acct-status-type)
Every accounting request carries acct-status-type, which says why the NAS sent it. In the event JSON these values are emitted in lower case: "start", "interim-update", "stop". Match them case-insensitively. The three you handle for subscriber sessions are:
start: the session just came up. Sent once when the subscriber connects.
interim-update: a periodic progress report for a live session. The NAS sends one every so often (see the interval below) carrying the session's usage counters so far.
stop: the session ended (the subscriber disconnected, was cut off, timed out, ...). Sent once, immediately, when the session goes down, and carries the final usage counters for the session.
So an accounting event happens every time the NAS/BNG reports on a session: a start when it begins, an interim-update on each periodic tick while it runs, and a stop the moment it ends.
Do not assume start is always the first event you see for a session. It is supposed to be, but a start can go missing between the BNG and the RADIUS server for various network reasons, so you may receive an interim-update (or even the stop) for a session whose start you never got. Treat the first event you see for a given session as the start of your knowledge of it, and do not block waiting for a start that may never arrive.
The interim interval is configured on the BNG, not by lunar. Operators normally set it between 10 minutes and 1 hour (3600 seconds) so they get regular usage snapshots without flooding the RADIUS server. Between interims you have no fresh counters for a session. The stop is sent immediately on disconnect regardless of the interim clock, so the final total never waits for the next tick.
Identifying a session (acct-session-id)
acct-session-id (type text) is the NAS's identifier for one session. It is unique per BNG: a single BNG never uses it for two of its sessions at once, so acct-session-id reliably ties a start / interim-update / stop sequence together for that BNG.
It is not globally unique: two different BNGs can independently pick the same acct-session-id for unrelated sessions. So always key a session on the BNG identity together with the session id (e.g. radius_nas_id / radius_nas_ip from the metadata plus acct-session-id) never on acct-session-id alone.
Usage counters and wrapping
acct-input-octets and acct-output-octets are the running total of bytes the session has transferred, as counted by the BNG: input is traffic from the subscriber, output is traffic to the subscriber. They only ever increase over the life of a session: each interim-update and the final stop carry the cumulative total so far, not the delta since the last event. To get the bytes used between two events, subtract the earlier counter from the later one:
acct-input-octets = 15315 // 15315 bytes in so far this session
acct-output-octets = 5877 // 5877 bytes out so far this session
Both are RFC 2866 integer attributes, i.e. 32-bit unsigned counters. A 32-bit counter can only hold up to 4 294 967 295 (2^32 - 1) and then wraps back to 0. On a fast or long-lived session that overflow happens easily, so a raw acct-input-octets of 100 might really mean 4 GiB + 100 bytes.
To recover the true total, RFC 2869 adds a companion counter for each direction:
acct-input-gigawords: how many times acct-input-octets has wrapped.
acct-output-gigawords: how many times acct-output-octets has wrapped.
Each "gigaword" is one full 32-bit wrap, i.e. 2^32 = 4 294 967 296 bytes. The BNG bumps the gigawords counter by one every time the matching octets counter rolls over. Reconstruct the real 64-bit total like this:
total_input_bytes = acct-input-gigawords * 4294967296 + acct-input-octets
total_output_bytes = acct-output-gigawords * 4294967296 + acct-output-octets
(4294967296 is 2^32.) Always apply this when a session may exceed 4 GiB in a direction. If the gigawords attribute is absent, treat it as 0. But be aware the octets counter alone cannot tell you it has wrapped, which is exactly why the gigawords companion exists. Both octets and gigawords are emitted as real JSON numbers (see "Value types").
Delivery semantics
Delivery is asynchronous and best-effort: under extreme load an event may be dropped rather than delay the RADIUS response to the NAS.
Ordering is only guaranteed between events that share a partition key (see "Partition key"). Across different keys there is no global order.
Every accepted accounting request produces an accounting event. Only requests that produced a valid decision produce an authentication event.