Lunar - RADIUS Integration
The subscriber service is lunar's RADIUS server. When it is enabled it attaches its own HTTP routes to the shared lunar API (see the Core API, where the listener and authentication are described).
This document is for developers of the backend API, the system that manages lunar instances (for example the NebularStack Subscriber Management module). It describes every route you can call on lunar to configure and inspect the RADIUS service. Several of these routes make lunar in turn call back to the backend API, so it also describes the exact shape and flow of those outbound calls.
All routes here share the lunar API's x-secret gate: send x-secret: <lunar.secret> on every request, or get 401 Unauthorized. All routes are under /v1 and speak JSON. The same secret authenticates both directions: your backend sends it to lunar when it calls lunar's routes, and lunar sends it back to your backend when it calls yours.
Two APIs: one on lunar, one on your backend
There are two HTTP APIs in play, and the whole integration is just a conversation between them:
The backend API lives on your backend. You build this. Lunar is the client that calls it, to get its configuration, and to get a decision for every RADIUS request it handles.
The lunar API lives on lunar. Lunar already provides this. You (or your management system) are the client that calls it, to add, update, remove and inspect virtuals, to check status, and to relay a CoA to a NAS.
Every route in the reference below carries a Service tag telling you which side hosts it, and it is only ever one of two values: lunar for a route that lives on lunar (you are the caller), or backend for a route that lives on your backend (lunar is the caller). There is no third kind, so if the tag says lunar you call it, and if it says backend you build and serve it.
Here is how the conversation actually flows:
Lunar starts up and asks your backend for its configuration. It calls your backend at GET /v1/lunar/<server_id>/virtuals, and your backend returns a JSON array of every virtual assigned to this lunar, each with its RADIUS clients. The exact shape your backend must return is shown at step 1 of the lifecycle. Lunar binds the listen ports and starts serving RADIUS. This one call bootstraps everything.
From then on, lunar calls your backend for every decision. For each RADIUS request a NAS/Client sends it (authentication, accounting, inbound CoA), lunar POSTs the packet to your backend and your backend answers (accept or reject, and so on). These, plus the config fetch above, are the routes you must build.
You call lunar to add or change a virtual. When you create or edit a virtual in your own system, you tell lunar with two routes on the lunar API: POST /v1/subscriber/radius/virtual/<id> to add or reload a virtual, and DELETE /v1/subscriber/radius/virtual/<id> to stop one. The POST carries no config itself. It just names the virtual. Lunar then calls back to your backend (GET /v1/lunar/<server_id>/virtual/<id>) to fetch that virtual's current configuration and applies it live, with no restart.
You call lunar to check on it. The lunar API also has read-only routes that report its status and list the virtuals and clients it is currently running (for example GET /v1/status and GET /v1/subscriber/radius/virtuals), plus the route that relays a CoA / Disconnect to a NAS.
Separately, lunar also talks RADIUS (binary UDP) to the NAS/Client devices on the network. That is the RADIUS wire (binary protocol RFC2865 and more), not HTTP API.
The rest of this guide simply follows that conversation in order, as one walkthrough you can read start to finish rather than a reference you page through. First the vocabulary it uses (a virtual and a client) and the one JSON packet format both sides speak. Then "The lifecycle, end to end": a single numbered sequence from the moment lunar boots to the logs it ships you last, with every route, in either direction, appearing at the step where it is called and its full spec inline. Read it top to bottom and you have walked the whole integration.
Minimum viable backend
If your goal is simply to make lunar work, you only need to serve a handful of routes, the ones lunar calls on you, all under the /v1/lunar/ prefix. Everything else in this guide is either something you call on lunar (the /v1/subscriber/radius/ routes) when you need it, or reference detail. The must-implement set is:
GET /v1/lunar/<server_id>/virtuals: return this server's virtuals and their clients. Called on startup and on reload. This bootstraps everything.
GET /v1/lunar/<server_id>/virtual/<virtual_id>: return one virtual's current config (used when a single virtual is reloaded).
POST /v1/lunar/<server_id>/auth/<virtual_id>: decide an Access-Request, return access-accept or access-reject.
POST /v1/lunar/<server_id>/acct/<virtual_id>: accept an accounting record and return 200 OK.
GET /v1/lunar/ping: health probe, answer 200 when ready.
POST /v1/lunar/<server_id>/log: accept a remote log line and return 200 OK. Lunar ships its subscriber log here as soon as the service starts (including the very errors that tell you something is misconfigured), so it is called whether or not you opt in. If you do not serve it, every log line lunar tries to ship fails, which is itself logged as an error, so treat it as required even if all your handler does is accept the POST and discard the body.
Add POST /v1/lunar/<server_id>/coa/<virtual_id> (inbound CoA) when you need it. All of these appear at their step in "The lifecycle, end to end" below, which walks the exact request and response shapes and the routes you call on lunar to drive it.
Concepts
Two objects run through that whole conversation, so everything that follows is described in terms of them: a virtual (a RADIUS server lunar hosts) and a client (a NAS that sends RADIUS to it). Every config lunar fetches, every packet it serves and every decision it asks for is scoped to one of these two, so they are worth pinning down before the routes.
A virtual
A virtual RADIUS server hosted inside one lunar instance. A single lunar can host many virtuals (for example one per business or service), each with its own single IPv4 listen address. On that one address a virtual binds exactly three UDP ports, one per RADIUS function:
radius_auth_port: authentication (Access-Request), default 1812.
radius_acct_port: accounting (Accounting-Request), default 1813.
radius_coa_port: dynamic authorization (RFC 5176 CoA / Disconnect, a.k.a. CoA/PoD or "coapod"), default 3799.
So a virtual is one address and three ports. There is only ever one of each port per virtual. Identified by an opaque virtual_id.
A client
A RADIUS client configured on a virtual, the NAS/BNG/BRAS/a Proxy that sends RADIUS to that virtual. A virtual can have many clients. Each is matched to an inbound packet by its source IPv4, and identified for the API by an opaque client_id unique within the virtual. A client record carries:
id: the opaque client id, unique within the virtual.
name: a friendly name for logging/diagnostics.
type: the client type (e.g. the NAS vendor/kind).
profile: an optional profile name (may be empty). When set it is passed to the backend API as x-nas-profile so policy can vary by profile.
ip4_address: the client's source IPv4. This is the match key: a request is accepted only if it arrives from this address.
secret: the RADIUS shared secret, used for all RADIUS crypto with this NAS (request/response authenticators and Message-Authenticator).
coa_port: the NAS's UDP port to send CoA / Disconnect to (RFC 5176), default 3799. Note this is the port on the NAS that lunar sends to, which is distinct from the virtual's own radius_coa_port it listens on.
req_message_auth: when true, require a valid Message-Authenticator on requests from this client (reject those without one).
message_auth_reply: when true, sign replies to this client with a Message-Authenticator. Note req_message_auth implies this: if req_message_auth is true then replies are always signed regardless of message_auth_reply (a client that must send a Message-Authenticator always gets one back), because req_message_auth already guarantees every accepted request carried one. Setting message_auth_reply on its own is the explicit opt-in for clients that do not require it on requests. Both flags apply to authentication (Access-*) replies only. Accounting replies are never signed, and CoA / Disconnect replies simply mirror the request (these flags do not apply there).
The JSON request format
Every RADIUS packet that crosses the HTTP boundary, in either direction, is the same JSON shape: a top-level code (the packet kind), the two wire fields id and authenticator, and an attributes object (its named fields). Lunar speaks it to you when it POSTs a request for a decision, and your backend speaks it back when it returns the decision. Get it right once and it applies to every call in this guide.
Request
Lunar -> backend (a request lunar forwards for a decision). When lunar sends you a packet it includes the top-level id and authenticator, and every attribute carries all three fields, values, type and code, exactly as lunar emits them (names and enum values lower-cased). A PAP access-request looks like:
{
"code": "access-request",
"id": 42,
"authenticator": "0x9a1f0c7e5b2d84a6f3e1c0b9d8a7e6f5",
"attributes": {
"user-name": {
"values": ["alice"],
"type": "text",
"code": "1"
},
"user-password": {
"values": ["s3cret"],
"type": "text",
"code": "2"
},
"nas-ip-address": {
"values": ["203.0.113.1"],
"type": "ipaddr",
"code": "4"
},
"nas-port": {
"values": [12],
"type": "integer",
"code": "5"
},
"service-type": {
"values": ["framed-user"],
"type": "integer",
"code": "6"
},
"framed-protocol": {
"values": ["ppp"],
"type": "integer",
"code": "7"
},
"calling-station-id": {
"values": ["aa-bb-cc-dd-ee-ff"],
"type": "text",
"code": "31"
}
}
}
id is the per-hop RADIUS identifier and authenticator is the 16-byte Request Authenticator, both as they arrived on the wire (the authenticator as 0x hex). You can ignore them for a plain PAP decision, but the authenticator matters for CHAP: when there is no chap-challenge attribute the challenge is the Request Authenticator (RFC 2865 s2.2), which is why lunar hands it to you. Also note service-type and framed-protocol come back as lower-case enum names (framed-user, ppp), not numbers, user-password arrives as decrypted cleartext text (lunar already undid the RADIUS obfuscation with the client secret), and integer values like nas-port are real JSON numbers, not strings.
Each attribute maps to a small object with three fields, values, type and code:
values is always an array, even for a single value. RADIUS has no list type: instead the same attribute may appear more than once in one packet, and that repetition is how it carries several values. Order is significant (RFC 2865 s4.1), so lunar keeps every occurrence in wire order in this array. It is always a list, so a consumer never has to special-case "scalar vs list".
type is the attribute's data type (text, integer, ipaddr, ...), which tells you how to read each entry in values.
code is the attribute's identity on the wire, and it is a string rather than a number because it is really a dotted code path: a standard attribute is a bare number ("1" for user-name), but a vendor attribute is the full path ("26.24757.1.1" = Vendor-Specific 26, IANA vendor 24757, attribute 1, sub 1), which is not a valid JSON number. A string expresses both uniformly.
When lunar forwards an attribute its dictionary does not know, it cannot give it a name or a type, so it emits it in a self-describing opaque form instead: the key is attr- followed by its dotted code path (attr-196 for standard attribute 196, attr-26.24757.1.1 for a vendor path), its type is always octets, and each value is the raw attribute bytes as a 0x hex string. There is no decoded form for these, so just read the hex (and echo it back unchanged if you need to). For example:
"attr-26.24757.1.1": {
"values": ["0x00000041"],
"type": "octets",
"code": "26.24757.1.1"
}
Response
Backend -> lunar (the decision your backend returns). Here you have it easy: you return only code and attributes (lunar computes the reply id and authenticator itself, so leave them out), and for a known attribute you may send just values and lunar fills type and code from its dictionary. So an access-accept can be as short as:
{
"code": "access-accept",
"attributes": {
"session-timeout": {"values": [3600]},
"framed-ip-address": {"values": ["100.64.0.10"]},
"class": {"values": ["0x6e733a3132"]}
}
}
An access-reject is the same with a different code (optionally a reply-message); accounting just needs 200 OK with no body at all.
Give each attribute in the form its type expects (the attribute-value rules are in the rest of this section): a typed value for a typed attribute (3600 for the integer session-timeout, "100.64.0.10" for the ipaddr framed-ip-address), and a 0x hex string for an opaque one (class, state). You never write the type field yourself on a reply; lunar knows each attribute's type from its dictionary.
To send an attribute lunar's dictionary does not know, use the same attr- form lunar emits (see the Request section above): key it attr- followed by its dotted code path and give the raw bytes as 0x hex. The code path in the key is authoritative, so you do not add a code field, and any type you set is ignored (lunar always treats these as opaque octets). For example:
"attr-26.24757.1.1": {"values": ["0x00000041"]}
Attribute value formats
Every attribute has a fixed data type, and that type is what decides how you write each entry in values. You never choose it and you never send it: for a known attribute lunar already knows the type from its loaded dictionary, so on a packet you send it lunar fills type (and code) in for you and ignores any type you set. It only flows the other way: on every packet lunar sends you it fills type in so you can read how each value is encoded. So when you build a packet you give only values, and you write each value in the form its type expects:
text: a UTF-8 string, e.g. "alice".
integer / short / byte / integer64: a whole number, given as a real JSON number (3600), not a quoted string. Some integer attributes are enums (named values), see "Enum attributes" below. For those you may give either the number or the enum name.
ipaddr / ipv6addr: an address in text form ("203.0.113.10", "2001:db8::1").
ipv4prefix / ipv6prefix: an address with prefix length ("2001:db8::/48").
ether: a MAC address ("00:11:22:33:44:55").
time: an ISO-8601 UTC string ("2025-01-01T00:00:00Z").
string / octets: opaque binary as 0x + hex, two hex digits per byte ("0x00000041" = the four bytes 00 00 00 41). class is the common example: echo it back unchanged, do not parse it.
For an attribute lunar's dictionary does not know, carry its identity in the key instead, attr-<dotted path> as shown in the Request and Response sections above, and give its values as 0x hex. type is ignored there too: an unknown attribute is always opaque octets.
Case-sensitivity
Casing works in two directions, and the rule is simple: you may send any case, and lunar always emits lower case. On input, attribute names, the packet code name and vendor names are all case-insensitive (User-Name, user-name and USER-NAME resolve to the same attribute, and access-request == Access-Request), so format requests however you like. On output, every packet lunar sends you is normalized to lower case: the attribute keys (user-name, nas-ip-address), the enum value names (see "Enum attributes") and the code name (access-request) all come back lower-cased. Because input is case-insensitive this round-trips cleanly: a body lunar sent you can be sent straight back unchanged. Every example in this document uses the lower case form lunar sends, so any body shown here is also a valid body to send. Attribute values are of course always kept exactly as given.
Enum attributes
Many integer attributes are enums: the RADIUS dictionary gives specific numeric values human names (acct-status-type value 1 is start). Lunar translates between the two: when you send a packet you may specify an enum value as either the integer or the name, and both are accepted interchangeably: {"values": [1]} and {"values": ["start"]} are identical. When lunar sends you a packet it emits the name in lower case (start, framed, interim-update) as a string where the dictionary knows one, and falls back to the bare number otherwise. So your backend should be ready to receive either form (matching enum names case-insensitively) and, ideally, accept either form on input too.
Enum names are matched case-insensitively like all attribute names. If you give a number the dictionary has no name for, it is passed through as-is.
A few basic enums from the standard RFC dictionaries:
acct-status-type (RFC 2866, accounting): start = 1, stop = 2, interim-update = 3, accounting-on = 7, accounting-off = 8.
service-type (RFC 2865): login-user = 1, framed-user = 2, administrative-user = 6, authenticate-only = 8, call-check = 10.
nas-port-type (RFC 2865): virtual = 5, ethernet = 15, wireless-802.11 = 19, cable = 17.
acct-terminate-cause (RFC 2866): user-request = 1, idle-timeout = 4, session-timeout = 5, admin-reset = 6.
For example these two are equivalent ways to say "interim-update":
"acct-status-type": {"values": ["interim-update"]}
"acct-status-type": {"values": [3]}
The full set of named values for every attribute lives in the RADIUS dictionary loaded on the lunar instance.
Tagged attributes
Some attributes are tagged (RFC 2868). A tag is a small number (0-255) that groups related attributes together, chiefly the tunnel attributes, so that when a reply offers several tunnels the NAS can tell which tunnel-type, tunnel-medium-type, tunnel-private-group-id etc. belong to the same tunnel. Attributes sharing a tag form one group.
In the API the tag is carried FreeRADIUS-style, as a numeric suffix on the attribute name after a colon, "Name:tag". There is no separate tag field. Each distinct tag is its own key. For example, two tunnels offered in one reply:
"attributes": {
"tunnel-type:1": {"values": ["l2tp"]},
"tunnel-medium-type:1": {"values": ["ipv4"]},
"tunnel-private-group-id:1": {"values": ["vlan100"]},
"tunnel-type:2": {"values": ["pptp"]},
"tunnel-medium-type:2": {"values": ["ipv4"]},
"tunnel-private-group-id:2": {"values": ["vlan200"]}
}
Here tag 1 and tag 2 are two independent tunnels. The shared tag is what ties each tunnel's attributes together.
Rules:
The suffix must be a number 0-255. A tag in the key forces tagging even if the dictionary does not mark the attribute as tagged.
A non-numeric suffix is treated as part of the name (no dictionary attribute name contains a colon), so a literal colon in a name is safe.
An untagged attribute simply has no suffix (the normal case). Do not add :0 unless you specifically mean tag 0.
When lunar sends tagged attributes back to you it uses this same Name:tag form, bucketing values by tag, so your backend both sends and receives them this way.
The lifecycle, end to end
Everything above was vocabulary. This is the walkthrough. It follows the integration in the order it actually happens, from the moment lunar boots to the logs it ships you at the end, and every route appears at the point in the story where it is called, with its full request and response spec inline. Read it straight down and you have watched the whole conversation play out: lunar asking you for its config, you answering, lunar deciding live traffic against you, and you driving lunar as virtuals come and go.
Every route below is one of two kinds, and each route's spec says which with a Service tag:
Service: backend means the route lives on your backend. You build and serve it; lunar is the caller.
Service: lunar means the route lives on lunar. Lunar already provides it; you are the caller.
Always trust that Service tag on the route itself. As a quick rule of thumb though, the path usually gives it away: you will generally see /v1/lunar/... routes on your backend (lunar calling you), and /v1/subscriber/radius/... routes on lunar (you calling lunar).
Every call in either direction carries the shared secret. On the routes you call on lunar, send x-secret: <lunar.secret> or get 401 Unauthorized. On the routes lunar calls on you, lunar sends that same secret back, alongside a fixed set of identity headers described at the first step below. All bodies are JSON.
1. Lunar boots and fetches its config from you
The story starts the instant lunar boots and reads its config file. The subscriber (RADIUS) service only comes up when it is switched on there, and the same file tells lunar where your backend lives and how to identify itself to it. The parts that matter for this guide look like this:
lunar:
# Your lunar server's identity, both provided by Interstellio IO.
server_id: 7f3c1a90-... # unique per lunar server
secret: s3cr3t-shared-key # shared secret, must match your backend
services:
# Turn the subscriber (RADIUS) service on.
subscriber: true
subscriber:
# Your backend API that lunar calls for config and decisions.
endpoint: https://your-backend.example.com
# Verify your backend's TLS certificate. Turn off only for local testing.
ssl_verify: true
If services.subscriber is left off (the default), none of this guide applies: lunar attaches no RADIUS routes and never calls your backend. With it on, subscriber.endpoint is the base URL lunar prefixes to every route it calls on you, and subscriber.ssl_verify controls TLS checking of that backend.
The other two, server_id and secret, are lunar's identity, and lunar presents both on every call it makes to you so you know which server is talking and can trust it:
server_id uniquely identifies this one lunar server. It is sent in the x-server-id header and is also part of every route path (/v1/lunar/<server_id>/...), so you can return the right virtuals for this instance and no other.
secret is the shared password between the two of you. Lunar sends it in the x-secret header on every call, and it is the very same secret you send back when you call lunar's routes: one credential, both directions.
Lunar adds one more header to every outbound call, which is not from the config but from lunar itself:
- x-lunar-version
Lunar's major version only (1 from 1.2.3), so your backend can adjust its handling per major version if it ever needs to. You will see it listed with the other headers on each backend route below.
Before it binds a single UDP port or serves one RADIUS packet, lunar makes the first of those calls to your backend to ask what it should be running. This one call bootstraps everything.
Complete, working implementations of every backend route in this walkthrough (in Python with Flask, Falcon, Falcon-async or FastAPI, plus Go, Node.js/Express, PHP, .NET and Java) live in the Interstellio examples repository: https://github.com/interstellio/examples/tree/master/lunar/radius-backend.
So, at boot lunar calls GET /v1/lunar/<server_id>/virtuals. It fetches its entire configuration from you before it serves any RADIUS packet. Identity headers only, no body. Your backend returns the virtuals assigned to this server, each with its clients (the exact shape is the schema right below this route).
Fetch All Virtuals for This Server.
backend
An empty array is valid (the server runs no virtuals). Anything that is not a 200 with a JSON array (a non-200, a non-array body, invalid JSON, a transport error) is treated as a transient failure: lunar logs it and retries every 5 seconds until it gets a valid array, so the backend can be brought up before or after lunar without a manual restart.
Each virtual in the array is then validated in two stages, and a failure at either is always logged (locally, and shipped to your log route as in step 9) so you can see exactly what was rejected and why:
When the config is read. A required field missing or the wrong type, an ip4_address that will not parse as IPv4, or a port outside 1..65535 makes the record malformed. A malformed virtual is skipped whole; a malformed client is skipped on its own, leaving its virtual and every other virtual running. One bad record never takes the whole fetch down.
When lunar binds the listen sockets. A record that passed the checks above can still be un-runnable on this host, and lunar refuses it (logs it and does not start that virtual, leaving the others running):
The ip4_address must actually be present on this machine, i.e. assigned to one of its interfaces. If it is on no interface the virtual is refused, unless the kernel is set to allow non-local binds (net.ipv4.ip_nonlocal_bind=1, e.g. a VRRP/keepalived standby holding a floating IP that has not arrived yet), in which case it is allowed with a warning.
Each of the three ports must be free to bind on that address. Two virtuals cannot share an ip:port, and a port already held by another process fails to bind. Ports below 1024 are privileged and will not bind unless lunar runs with the rights to use them, so keep the three ports in the 1024..65535 range (the defaults 1812 / 1813 / 3799 already are).
On the startup fetch a refused virtual is simply not started. On a later single virtual update (step 4) the same refusal instead leaves the virtual running with its previous, still-valid configuration rather than tearing it down.
The route below renders every field, its type, whether it is required and a full example; the client fields are the same ones described under "A client".
2. Lunar is live: it asks you to decide every request
With its config loaded and its ports bound, lunar is now serving RADIUS. From here on, for every packet a NAS sends it, lunar turns to you for the decision. This is the core of the RADIUS engine and the call your backend answers most. For every RADIUS request received on a virtual's listen ports that needs a decision, lunar decodes the wire packet to the packet JSON above and POSTs it to the backend. The backend's response drives the RADIUS reply. This covers all three inbound RADIUS functions (one per listen port) distinguished by the <type> path segment. The .../test route exercises this path on demand (for auth and acct only).
The three functions share one route shape, POST <subscriber.endpoint>/v1/lunar/<server_id>/<type>/<virtual_id>, where <type> is which listen port the request arrived on:
auth: Access-Request / Status-Server (the auth port).
acct: Accounting-Request (the acct port).
coa: an inbound CoA / Disconnect the NAS (or an upstream proxy) sent to this virtual's CoA port, forwarded to the backend for a decision. Do not confuse this with the outbound coapod relay (backend -> lunar -> NAS) covered later in step 7. That is a different direction and does not use this endpoint.
Each carries the identity headers plus the per-request headers x-client-ip, x-client-id, x-nas-id and x-nas-profile (only when set), and a body that is the request packet as RADIUS packet JSON (the format and attribute rules above apply exactly). The three are documented separately below because their request and response codes differ.
Authentication Decision (auth).
backend
Accounting Record (acct).
backend
Inbound CoA / Disconnect Decision (coa).
backend
Request and response codes per type (auth/acct/coa)
Each <type> forwards a specific set of request codes and expects a specific set of valid response codes in the 200 OK body.
- auth
Request codes: access-request (the normal case) and status-server (RFC 5997 health probe on the auth port).
Valid response codes: access-accept or access-reject for an access-request, access-challenge to continue a multi-round exchange (e.g. EAP), and access-accept to answer a status-server.
- acct
Request codes: accounting-request and status-server (RFC 5997 probe on the acct port).
Valid response code: accounting-response (and for a status-server, accounting-response). But see the accounting note below. Lunar fast-ACKs accounting itself, so in practice the backend just needs to return 200 OK.
- coa (inbound)
Request codes: coa-request and disconnect-request (RFC 5176), received on the virtual's CoA port.
Valid response codes: coa-ack / coa-nak for a coa-request, and disconnect-ack / disconnect-nak for a disconnect-request.
Expected response for auth and coa: 200 OK with a packet JSON body whose code is one of the valid response codes above, plus any reply attributes (e.g. class, rate limits). Lunar encodes those attributes back onto the RADIUS wire to the sender, so specify them using the same attribute format as above.
Accounting response: in normal operation lunar fast-ACKs accounting to the NAS immediately (RFC 2866) and delivers the accounting body to the backend out-of-band (fire-and-forget with a few retries) so the backend's accounting response body is not used to answer the NAS. The backend should still accept the POST and return 200 OK to confirm receipt. Any reply attributes it returns for accounting are ignored.
Timing, duplicates and unknown clients
A few operational realities of the RADIUS wire your backend must live with:
Response deadline. Lunar waits only a short time for your backend to answer an auth/coa forward, and if you miss it lunar drops the exchange (the NAS gets no reply and will retransmit). How long it waits is set in lunar's config file, defaults to 2 seconds and is capped at 5:
protocol: radius: # Seconds lunar waits for your backend to answer (max 5). request_timeout: 2So keep the auth/coa decision fast and push slow work (provisioning, billing) off the request path.
Duplicates. RADIUS is UDP, so a NAS retransmits when it does not get a timely reply. Lunar suppresses duplicates that are still in flight (it will not call your backend twice for the same request while the first is being handled), but a retransmit that arrives after the first completed is a fresh request and is forwarded again. So your backend may occasionally see the same logical request more than once. Make auth decisions repeatable and accounting writes idempotent (key on acct-session-id + the NAS identity).
Unknown clients. A packet whose source IP matches no configured client on the virtual is dropped by lunar and never reaches your backend (logged as "Unknown client ignored packet"). A NAS must have a client record (with its shared secret) before its traffic is served. That is what the config fetch / virtual update provides.
Malformed reply attributes are skipped, not fatal
Lunar is deliberately tolerant of individual bad attributes in your response, so one broken attribute never costs the subscriber their reply. When it builds the wire reply, any single attribute it cannot use is dropped and the rest of the packet is still sent to the NAS. This happens when:
the attribute name is not in the loaded dictionary and no code was supplied (lunar has no way to place it on the wire).
a value cannot be parsed/cast to the attribute's type (e.g. a non-numeric string for an integer, a bad IP, malformed hex).
a value is too large for the attribute and would be truncated on the wire.
Every such case is logged (see "Remote logging", "APIClient reply attributes skipped" / "lossy", with the client/virtual context and the exact attribute and reason), so you can find and fix the offending attribute in your backend. The subscriber still gets the decision. Only the unusable attribute is missing.
The one exception is a response body that is not valid packet JSON at all (not JSON, or not an object): there is nothing to send, so no reply goes to the NAS and lunar logs it at critical. Only a whole-body failure costs the reply. A single bad attribute never does. (The same per-attribute tolerance applies to requests lunar decodes from the NAS: a broken inbound attribute is skipped and logged, and the request is still forwarded.)
Verifying the credential: PAP, CHAP, MS-CHAP, MS-CHAPv2
How you actually verify the credential in an auth request depends on the method the NAS used (PAP, CHAP, MS-CHAPv1 or MS-CHAPv2), and each needs a different check. The full step-by-step verification of every method, with the exact attribute byte layouts, the MD4 / MD5 / DES / SHA1 primitives, the MS-CHAPv2 mutual-auth and MPPE keys, the reasoning behind the single-call design, and links to working implementations you can copy, is documented in the Authentication guide.
PAP (user-password): lunar decrypts it with the client's shared secret and forwards the cleartext password as a normal text value. Your backend compares it directly. No RADIUS crypto needed.
CHAP (chap-password + chap-challenge), MS-CHAP / MS-CHAPv2 (ms-chap-*): forwarded as raw bytes (0x hex), untouched. Your backend runs the challenge/response algorithm itself against the stored password. The request authenticator field is included in the body, so you have the Request Authenticator when a CHAP-Challenge is absent (RFC 2865 s2.2 uses the authenticator as the challenge). We provide working examples of each method in the Interstellio examples repository (https://github.com/interstellio/examples/tree/master/lunar/radius-backend), and see the Authentication guide.
EAP (eap-message): forwarded as raw bytes, and the multi-round exchange is carried with access-challenge responses (see below). Lunar transports EAP but does not itself terminate any EAP method. A specific method such as EAP-TLS would run in your backend (built-in EAP method termination is a roadmap item, see Authentication).
So a backend supporting only PAP just reads user-password. Supporting CHAP or EAP means implementing those algorithms from the raw attributes. Step-by-step verification of each method, with links to working code, is in the Authentication guide.
What lunar handles for you (do not add these)
Lunar owns the wire mechanics, so your response should carry only the decision code and real reply attributes:
The RADIUS id (identifier): echoed from the request automatically. Any id in your response body is ignored.
The response authenticator: computed and written by lunar.
message-authenticator: added and signed by lunar per the client's policy (see the req_message_auth / message_auth_reply client flags). Do not put one in your response.
proxy-state: echoed back to the NAS unchanged automatically (RFC 2865 s5.33). Do not echo it yourself.
3. Lunar health-checks you
Alongside the per-request traffic, lunar polls your backend so it knows you are reachable. The backend must answer 200 OK for the subscriber service to report healthy on GET /v1/status (see the Core API). Identity headers only, no body, probed with a 5 second timeout.
Health Ping.
backend
4. You add or update a virtual (and lunar calls you back)
Everything so far has been lunar calling you. Now the other direction: you driving lunar. When you create or edit a virtual in your own system, you tell lunar with a single call. It carries no config in its body, it only names the virtual, and it returns 204 No Content immediately while the RADIUS engine does the work asynchronously. Queuing the same task again for a virtual supersedes one still waiting.
Update or Start a Virtual.
lunar
That 204 is not the end of it. This is the round trip that matters: having been told which virtual changed, lunar immediately turns around and calls you back to fetch that one virtual's current config (identity headers only, no body, 10 second timeout), then applies it live:
200 OK with a single virtual object (the same schema as one array element from the startup fetch in step 1). Lunar applies it: if the virtual is not running it is started, if it is running it is updated in place.
404 Not Found is authoritative: the virtual no longer belongs to this server, so lunar stops the running instance rather than leave it serving stale config. (Deleting a virtual is therefore: remove it on the backend so it 404s, then trigger this update route, or call the DELETE route in step 5.)
Any other failure (timeout, 5xx, DNS) is transient: lunar keeps the virtual running as-is with its existing config and logs the error.
So one POST from you becomes one GET straight back to you. This is that callback, the backend route lunar calls to complete the update:
Fetch One Virtual's Config.
backend
Config updates never disrupt live traffic
Applying a fetched virtual is a hot, in-place swap. It does not restart any thread and does not drop or delay in-flight RADIUS packets. Listen sockets are only touched if the listen IP or one of the three ports actually changed. The client set is swapped atomically (read-copy-update), so packet threads reading the old client set finish on it and pick up the new set on their next lookup. In short: updating configuration has no effect on the processing of live RADIUS packets: authentication, accounting and CoA continue uninterrupted while the new config takes over.
5. You remove a virtual
To stop a virtual outright, call the same route with DELETE. Like the update above it returns 204 No Content immediately and stops the virtual asynchronously. The id alone selects it, there is no body.
Stop a Virtual.
lunar
6. You inspect what lunar is running
At any time you can read back the live running config. These are all GET on lunar, served from lock-free snapshots, so they are always safe to call and never block the RADIUS packet path. The list routes return identity only. Fetch a single virtual or client for its full record.
List Running Virtuals.
lunar
Get One Virtual.
lunar
List a Virtual's Clients.
lunar
Get One Client.
lunar
7. You push a CoA or Disconnect out to a NAS
Dynamic authorization (RFC 5176 CoA / Disconnect) can flow either way, and the two directions are easy to mix up, so keep them straight:
Inbound (NAS -> lunar -> backend) was step 2: a NAS sends a CoA / Disconnect to the virtual's CoA port and lunar forwards it to you as type coa for a decision. That is a normal request you answer, back in step 2.
Outbound (backend -> lunar -> NAS) is this step, the coapod relay: you initiate a CoA / Disconnect and lunar puts it on the RADIUS wire to the NAS. There is no separate backend endpoint for this. You both send the request and read the reply on this one lunar route, in packet JSON.
This is how you push a mid-session change (re-auth, new rate limit) or a forced disconnect down to a subscriber's NAS. The body's code must be coa-request or disconnect-request. (Legacy equivalent, identical behavior: POST /v1/coapod.)
Flow: lunar resolves the client (its address, CoA port and shared secret) on the named virtual, builds the RADIUS packet, assigns its own rotating RADIUS identifier and computes the request authenticator itself (so any id or authenticator you put in the body is ignored), always adds and signs a Message-Authenticator (post BlastRADIUS / CVE-2024-3596 best practice), and sends it over UDP from the virtual's listen address to the NAS. Per RFC 5176 it retries up to 3 times with a 2 second response timeout, then parses the NAS reply (ACK or NAK), verifies the reply's authenticator against the request, and returns the reply. On 400 the body is an error object with a retries count when the failure came from sending, for example {"error": "reply has invalid authenticator! (Shared secret is incorrect)", "retries": 3}. Every failure is also logged on lunar with full relay context (virtual, NAS name, destination ip:port, packet type, source ip), so a failed relay is diagnosable from the lunar side, not only from the error string. A coa-request works the same way: send it with the reply attributes to apply (rate limits, a new filter-id, ...) and read back coa-ack or coa-nak.
Relay a CoA / Disconnect to a NAS.
lunar
8. You test a request, or probe a virtual end to end
Two lunar routes let you verify a deployment without waiting for real NAS traffic.
The first simulates a RADIUS request as a given client and returns the backend API's answer, without sending anything on the RADIUS wire. Use it to check that a client's identity and your backend policy produce the expected decision. The body's code must be access-request or status-server (routed to your auth endpoint from step 2) or accounting-request (routed to your acct endpoint). Lunar processes the posted packet exactly as the live engine would and re-encodes it, so the body forwarded is exactly what a real request from this client would produce regardless of how you formatted the input. It then forwards it to your backend as this client and returns your response verbatim, including your own status code. A 502 (body {"error": "<reason>"}) means your backend could not be reached (timeout, DNS, connection).
Test a Request Against the Backend.
lunar
The second proves that one virtual can process RADIUS end to end, right now. Lunar builds an RFC 5997 Status-Server probe and sends it from the virtual's own listen address to its own auth port, so the probe traverses the entire engine (inbound listener, processing threads, the backend API round-trip, reply build and UDP send) and only succeeds once a validated reply comes back. A 200 therefore proves the whole chain is alive, not merely that the lunar process is up. It is idempotent and safe to poll (hence GET). Because it rides the full path it needs a reachable, healthy backend: its Status-Server is forwarded to your auth endpoint (which answers access-accept), so a 502 (body {"status": "bad", "error": "<reason>"}, mirroring GET /v1/status) can also mean the backend did not answer in time. This is the inbound per-virtual probe (backend -> lunar). Do not confuse it with the outbound health ping lunar sends you in step 3 (GET .../v1/lunar/ping).
Virtual Liveness Probe.
lunar
9. Lunar ships its logs to you
Lunar ships its subscriber service log lines to the backend so operators can see them centrally. This is registered at service start (skipped entirely in testing mode, where there is no backend). message is the log text (any structured meta lunar attached is also appended in (key:value) form), level is one of debug, info, warning, error or critical, and time is Unix time in seconds. Every meta pair lunar attached (e.g. virtual_id, client_ip) is also emitted as a top-level field, so the backend can read those directly. The reserved message / level / time keys always win if a meta key would collide.
Delivery is best-effort and asynchronous: the line is already written to lunar's local log, so a failed POST is only reported locally and the remote copy is dropped. It never blocks or delays RADIUS processing. Shipping is hard-capped (per-second limit) so a log storm cannot flood the backend. The backend should simply accept the POST and return 200 OK.
Remote Logging.
backend
What lunar logs remotely, and what it does not
Lunar makes no authentication decision. It forwards the request and the backend decides. So lunar does not know, and never logs, whether an authentication succeeded or failed: an access-reject is a perfectly normal outcome the backend chose, not an error. If you want a record of authentication successes/failures, log them in your backend at the point you make the decision (you have the full request and your own reason there).
Lunar's remote log is therefore for problems only it can see, things that mean "something is misconfigured or broken and needs a human", for example:
invalid or corrupt packets received from a NAS (malformed wire framing, truncated attributes).
a wrong shared secret between a NAS and its client record (a failed Request Authenticator / Message-Authenticator).
a required Message-Authenticator missing from a client configured with req_message_auth.
a CoA / Disconnect relay that failed (no reply from the NAS, bad secret).
a response from your backend that lunar could not encode (an unknown attribute name, an invalid value for a type, or a value too large), so you learn your backend produced something lunar cannot put on the wire.
In a healthy, correctly configured deployment this stream is nearly silent. Do not expect a message per authentication or per packet. That is the whole point of the split: the backend owns the auth decision and its own audit log, while lunar only escalates the rare "this needs fixing" events. Pushing every auth decision back to lunar (or making lunar log each one with a second API call) would be far slower and would not scale. Keeping lunar's fast path free of per-request logging is deliberate.