Skip to main content

netget/server/radius/
mod.rs

1//! RADIUS server (RFC 2865 authentication/authorization, RFC 2866 accounting).
2//!
3//! The model makes the authorization decision. This file owns the socket, the codec and —
4//! most importantly — the guarantee that **no decision means denial**.
5//!
6//! See `src/server/radius/CLAUDE.md` for the design rationale and the list of things this
7//! server deliberately does not implement.
8
9pub mod actions;
10pub mod packet;
11
12pub use actions::RadiusProtocol;
13
14use crate::llm::action_helper::call_llm;
15use crate::protocol::{Event, SpawnContext};
16use crate::server::connection::ConnectionId;
17use crate::state::app_state::AppState;
18use crate::state::ServerId;
19use anyhow::{Context, Result};
20use std::net::SocketAddr;
21use std::sync::Arc;
22use tokio::net::UdpSocket;
23use tokio::sync::mpsc;
24use tracing::{debug, error, trace, warn};
25
26use crate::logging::emit::Log;
27
28use actions::{
29    RADIUS_ACCESS_REQUEST_EVENT, RADIUS_ACCOUNTING_REQUEST_EVENT, RADIUS_STATUS_SERVER_EVENT,
30};
31use packet::{Attribute, RadiusPacket};
32
33/// How the reply was arrived at. This exists so the log can never conflate "the model said
34/// no" with "the model said nothing" — the OAuth2 failure mode, where those two collapsed
35/// into one another and silence became approval.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Decision {
38    /// The model returned `send_access_accept`.
39    ModelAccept,
40    /// The model returned `send_access_reject`.
41    ModelReject,
42    /// The model returned `send_access_challenge`.
43    ModelChallenge,
44    /// The model returned `send_accounting_response`.
45    ModelAccountingResponse,
46    /// The model returned no usable action. The server denies.
47    FailClosedNoAction,
48    /// The LLM call itself failed. The server denies.
49    FailClosedLlmError,
50    /// The model's action could not be encoded. The server denies.
51    FailClosedActionError,
52}
53
54impl Decision {
55    /// Stable token used in logs and in the status stream. Grep-able, and distinct per path.
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Decision::ModelAccept => "model_accept",
59            Decision::ModelReject => "model_reject",
60            Decision::ModelChallenge => "model_challenge",
61            Decision::ModelAccountingResponse => "model_accounting_response",
62            Decision::FailClosedNoAction => "fail_closed_no_action",
63            Decision::FailClosedLlmError => "fail_closed_llm_error",
64            Decision::FailClosedActionError => "fail_closed_action_error",
65        }
66    }
67
68    /// True when the server, not the model, chose to deny.
69    pub fn is_fail_closed(&self) -> bool {
70        matches!(
71            self,
72            Decision::FailClosedNoAction
73                | Decision::FailClosedLlmError
74                | Decision::FailClosedActionError
75        )
76    }
77}
78
79/// Classify a reply the model produced, by its RADIUS code.
80fn decision_for_code(code: u8) -> Option<Decision> {
81    match code {
82        packet::CODE_ACCESS_ACCEPT => Some(Decision::ModelAccept),
83        packet::CODE_ACCESS_REJECT => Some(Decision::ModelReject),
84        packet::CODE_ACCESS_CHALLENGE => Some(Decision::ModelChallenge),
85        packet::CODE_ACCOUNTING_RESPONSE => Some(Decision::ModelAccountingResponse),
86        _ => None,
87    }
88}
89
90pub struct RadiusServer;
91
92impl RadiusServer {
93    /// Bind the socket and start serving.
94    ///
95    /// Returns `Err` — so `server_startup` sets `ServerStatus::Error` — if the socket cannot
96    /// be bound, or if no `shared_secret` was supplied. Running without a secret is not an
97    /// option: the secret keys both the Response Authenticator and the User-Password
98    /// decryption, and inventing a default would be exactly the fail-open pattern this
99    /// protocol exists to avoid.
100    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
101        let listen_addr = ctx.legacy_listen_addr();
102        let SpawnContext {
103            llm_client,
104            state,
105            status_tx,
106            server_id,
107            startup_params,
108            ..
109        } = ctx;
110
111        let params = startup_params.context(
112            "RADIUS requires a shared_secret startup parameter; none were supplied. \
113             Start the server with startup_params: {\"shared_secret\": \"...\"}",
114        )?;
115        let secret: Arc<Vec<u8>> = Arc::new(params.get_string("shared_secret")?.into_bytes());
116        if secret.is_empty() {
117            return Err(anyhow::anyhow!(
118                "RADIUS shared_secret must not be empty: it keys the Response Authenticator \
119                 and the User-Password decryption"
120            ));
121        }
122
123        let socket = Arc::new(
124            UdpSocket::bind(listen_addr)
125                .await
126                .with_context(|| format!("RADIUS failed to bind {}", listen_addr))?,
127        );
128        let local_addr = socket.local_addr()?;
129
130        Log::new(Some(&status_tx)).info(format!("RADIUS server listening on {}", local_addr));
131
132        let task_registrar = state.clone();
133        let accept_handle = tokio::spawn(async move {
134            // RFC 2865 §3 caps a packet at 4096 octets. A larger datagram is over-long and
135            // is rejected by the decoder rather than silently truncated here.
136            let mut buffer = vec![0u8; packet::MAX_PACKET_LEN + 1];
137
138            loop {
139                let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
140                    Ok(pair) => pair,
141                    Err(e) => {
142                        Log::new(Some(&status_tx)).error(format!("RADIUS receive error: {}", e));
143                        break;
144                    }
145                };
146
147                let data = buffer[..n].to_vec();
148                trace!(
149                    "RADIUS {} bytes from {}: {}",
150                    n,
151                    peer_addr,
152                    hex::encode(&data)
153                );
154
155                let request = match RadiusPacket::decode(&data) {
156                    Ok(p) => p,
157                    Err(e) => {
158                        Log::new(Some(&status_tx))
159                            .warn(format!("RADIUS dropped datagram from {}: {}", peer_addr, e));
160                        continue;
161                    }
162                };
163
164                // A Message-Authenticator that does not verify (RFC 3579 §3.2): the sender
165                // does not hold the shared secret, so the packet is silently discarded.
166                if let Err(e) = packet::verify_request_message_authenticator(&data, &secret) {
167                    Log::new(Some(&status_tx)).warn(format!(
168                        "RADIUS dropped {} id={} from {}: {}",
169                        packet::code_name(request.code),
170                        request.identifier,
171                        peer_addr,
172                        e
173                    ));
174                    continue;
175                }
176
177                // Accounting-Request carries a verifiable authenticator. A mismatch means
178                // the sender does not hold the shared secret, so the packet is dropped —
179                // this is the one inbound integrity check RADIUS actually affords.
180                if request.code == packet::CODE_ACCOUNTING_REQUEST {
181                    if let Err(e) = packet::verify_accounting_request(&request, &secret) {
182                        Log::new(Some(&status_tx)).warn(format!(
183                            "RADIUS dropped Accounting-Request id={} from {}: {}",
184                            request.identifier, peer_addr, e
185                        ));
186                        continue;
187                    }
188                }
189
190                Self::record_connection(&state, server_id, local_addr, peer_addr, n).await;
191                let _ = status_tx.send("__UPDATE_UI__".to_string());
192
193                debug!(
194                    "RADIUS {} id={} from {} ({} attributes)",
195                    packet::code_name(request.code),
196                    request.identifier,
197                    peer_addr,
198                    request.attributes.len()
199                );
200
201                let llm = llm_client.clone();
202                let st = state.clone();
203                let tx = status_tx.clone();
204                let sock = socket.clone();
205                let sec = secret.clone();
206
207                // Tracked, not detached: stop_server must abort this task too.
208                let task_owner = state.clone();
209                task_owner
210                    .spawn_server_task(server_id, async move {
211                        Self::handle_request(request, peer_addr, sock, llm, st, tx, server_id, sec)
212                            .await;
213                    })
214                    .await;
215            }
216        });
217
218        task_registrar
219            .register_server_task(server_id, accept_handle)
220            .await;
221
222        Ok(local_addr)
223    }
224
225    async fn record_connection(
226        state: &Arc<AppState>,
227        server_id: ServerId,
228        local_addr: SocketAddr,
229        peer_addr: SocketAddr,
230        bytes: usize,
231    ) {
232        use crate::state::server::{
233            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
234        };
235        let connection_id = ConnectionId::new(state.get_next_unified_id().await);
236        let now = crate::utils::clock::Instant::now();
237        let conn_state = ServerConnectionState {
238            id: connection_id,
239            remote_addr: peer_addr,
240            local_addr,
241            bytes_sent: 0,
242            bytes_received: bytes as u64,
243            packets_sent: 0,
244            packets_received: 1,
245            last_activity: now,
246            status: ConnectionStatus::Active,
247            status_changed_at: now,
248            protocol_info: ProtocolConnectionInfo::empty(),
249        };
250        state.add_connection_to_server(server_id, conn_state).await;
251    }
252
253    /// Ask the model, apply the fail-closed rule and send the reply on the UDP socket.
254    #[allow(clippy::too_many_arguments)]
255    async fn handle_request(
256        request: RadiusPacket,
257        peer_addr: SocketAddr,
258        socket: Arc<UdpSocket>,
259        llm_client: crate::llm::ollama_client::OllamaClient,
260        state: Arc<AppState>,
261        status_tx: mpsc::UnboundedSender<String>,
262        server_id: ServerId,
263        secret: Arc<Vec<u8>>,
264    ) {
265        let (code, identifier) = (request.code, request.identifier);
266        let Some(reply) = Self::answer(
267            request,
268            peer_addr,
269            None,
270            llm_client,
271            state,
272            status_tx.clone(),
273            server_id,
274            secret,
275        )
276        .await
277        else {
278            debug!(
279                "RADIUS sending nothing for {} id={} from {}",
280                packet::code_name(code),
281                identifier,
282                peer_addr
283            );
284            return;
285        };
286
287        match socket.send_to(&reply, peer_addr).await {
288            Ok(sent) => {
289                trace!(
290                    "RADIUS sent {} bytes to {}: {}",
291                    sent,
292                    peer_addr,
293                    hex::encode(&reply)
294                );
295            }
296            Err(e) => {
297                Log::new(Some(&status_tx))
298                    .error(format!("RADIUS failed to reply to {}: {}", peer_addr, e));
299            }
300        }
301    }
302
303    /// Ask the model about one request and apply the fail-closed rule: the reply to send, or
304    /// `None` when the request must go unanswered (accounting the model did not acknowledge, or
305    /// a code that is not a request). Shared by the UDP server and RadSec, which carries the
306    /// same packets over TLS.
307    #[allow(clippy::too_many_arguments)]
308    pub async fn answer(
309        request: RadiusPacket,
310        peer_addr: SocketAddr,
311        connection_id: Option<ConnectionId>,
312        llm_client: crate::llm::ollama_client::OllamaClient,
313        state: Arc<AppState>,
314        status_tx: mpsc::UnboundedSender<String>,
315        server_id: ServerId,
316        secret: Arc<Vec<u8>>,
317    ) -> Option<Vec<u8>> {
318        let protocol = actions::RadiusProtocol::for_request(actions::RequestContext {
319            identifier: request.identifier,
320            authenticator: request.authenticator,
321            secret: secret.as_ref().clone(),
322            proxy_state: request
323                .all(packet::ATTR_PROXY_STATE)
324                .into_iter()
325                .map(|v| v.to_vec())
326                .collect(),
327        });
328
329        let event = match request.code {
330            packet::CODE_ACCESS_REQUEST => Event::new(
331                &RADIUS_ACCESS_REQUEST_EVENT,
332                Self::access_request_data(&request, peer_addr, &secret),
333            ),
334            packet::CODE_ACCOUNTING_REQUEST => Event::new(
335                &RADIUS_ACCOUNTING_REQUEST_EVENT,
336                Self::accounting_request_data(&request, peer_addr),
337            ),
338            packet::CODE_STATUS_SERVER => Event::new(
339                &RADIUS_STATUS_SERVER_EVENT,
340                serde_json::json!({
341                    "identifier": request.identifier,
342                    "source_addr": peer_addr.to_string(),
343                    "attributes": actions::attributes_json(&request),
344                }),
345            ),
346            other => {
347                // Responses and unknown codes are not requests; a server must not answer
348                // them. Dropping is the RFC 2865 §3 behaviour for an invalid code.
349                warn!(
350                    "RADIUS ignoring {} (code {}) from {}: not a request this server serves",
351                    packet::code_name(other),
352                    other,
353                    peer_addr
354                );
355                return None;
356            }
357        };
358
359        let llm_outcome = call_llm(
360            &llm_client,
361            &state,
362            server_id,
363            connection_id,
364            &event,
365            &protocol,
366        )
367        .await;
368
369        let (decision, reply) =
370            Self::decide(&request, llm_outcome, &protocol, &status_tx, peer_addr);
371
372        // Log the decision before writing it, and make the fail-closed paths loud. The token
373        // is stable so an operator can grep for `decision=fail_closed_` and find every
374        // request the model did not actually answer.
375        let summary = format!(
376            "RADIUS {} id={} from {} decision={}",
377            packet::code_name(request.code),
378            request.identifier,
379            peer_addr,
380            decision.as_str()
381        );
382        let log = Log::new(Some(&status_tx));
383        if decision.is_fail_closed() {
384            log.error(format!(
385                "{} (denied because no usable decision was produced)",
386                summary
387            ));
388        } else {
389            log.info(&summary);
390        }
391        reply
392    }
393
394    /// **The fail-closed rule.**
395    ///
396    /// Returns the decision taken and the bytes to send, if any.
397    ///
398    /// - A usable model action is used verbatim.
399    /// - No usable action, or an LLM error, on an *authorization* request produces a
400    ///   synthesised Access-Reject and a `fail_closed_*` decision. It is never reported as
401    ///   `model_reject`: a model that denies and a model that is unreachable must remain
402    ///   distinguishable, which is precisely what OAuth2 lost.
403    /// - Accounting has nothing to deny, so its safe default is *silence*: no
404    ///   Accounting-Response, and the NAS retransmits.
405    fn decide(
406        request: &RadiusPacket,
407        llm_outcome: Result<crate::llm::ExecutionResult>,
408        protocol: &actions::RadiusProtocol,
409        status_tx: &mpsc::UnboundedSender<String>,
410        peer_addr: SocketAddr,
411    ) -> (Decision, Option<Vec<u8>>) {
412        let is_authorization = packet::is_authorization_request(request.code);
413
414        let log = Log::new(Some(status_tx));
415        let execution = match llm_outcome {
416            Ok(result) => {
417                for message in &result.messages {
418                    log.info(message);
419                }
420                result
421            }
422            Err(e) => {
423                // RADIUS has exactly one way to say no, so an overloaded backend and a dead
424                // one both produce the same Access-Reject on the wire. The distinction is
425                // still worth having, so it goes in the log next to the decision token —
426                // the error itself is logged here and never reaches the peer.
427                let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
428                    crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
429                    crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
430                };
431                log.error(format!(
432                    "RADIUS LLM call failed for {} (category={}): {}",
433                    peer_addr, category, e
434                ));
435                return (
436                    Decision::FailClosedLlmError,
437                    Self::synthesised_reject(request, protocol, is_authorization),
438                );
439            }
440        };
441
442        // Take the first output that decodes as a RADIUS reply. Extra outputs are a model
443        // error, not a licence to send several packets for one request.
444        let mut chosen: Option<(Decision, Vec<u8>)> = None;
445        let mut extra = 0usize;
446        for protocol_result in &execution.protocol_results {
447            for output in protocol_result.get_all_output() {
448                let code = output.first().copied().unwrap_or(0);
449                match decision_for_code(code) {
450                    Some(decision) if chosen.is_none() => chosen = Some((decision, output)),
451                    _ => extra += 1,
452                }
453            }
454        }
455        if extra > 0 {
456            warn!(
457                "RADIUS ignored {} extra output(s) for id={} from {}; one request gets one reply",
458                extra, request.identifier, peer_addr
459            );
460        }
461
462        if let Some((decision, bytes)) = chosen {
463            return (decision, Some(bytes));
464        }
465
466        // Nothing usable came back. If the model produced actions at all, they failed to
467        // encode; if it produced none, it stayed silent. Both deny, and both are recorded
468        // as the server's decision rather than the model's.
469        let decision = if execution.raw_actions.is_empty() {
470            Decision::FailClosedNoAction
471        } else {
472            Decision::FailClosedActionError
473        };
474        (
475            decision,
476            Self::synthesised_reject(request, protocol, is_authorization),
477        )
478    }
479
480    /// Build the Access-Reject the server sends when the model did not decide.
481    ///
482    /// The Reply-Message deliberately differs from anything the model can produce, so the
483    /// two denial paths are distinguishable on the wire as well as in the log.
484    fn synthesised_reject(
485        request: &RadiusPacket,
486        protocol: &actions::RadiusProtocol,
487        is_authorization: bool,
488    ) -> Option<Vec<u8>> {
489        if !is_authorization {
490            // Accounting: stay silent. There is no "deny" to express, and an unearned
491            // Accounting-Response would tell the NAS its record was stored when it was not.
492            return None;
493        }
494
495        let mut attributes = vec![Attribute::text(
496            packet::ATTR_REPLY_MESSAGE,
497            "Access denied: no authorization decision was produced",
498        )];
499        for state in protocol.proxy_state() {
500            attributes.push(Attribute::new(packet::ATTR_PROXY_STATE, state.clone()));
501        }
502
503        match protocol.encode_reply(packet::CODE_ACCESS_REJECT, &attributes) {
504            Ok(bytes) => Some(bytes),
505            Err(e) => {
506                // Encoding a 1-attribute Access-Reject cannot realistically fail, but if it
507                // did, silence is still a denial — never an accept.
508                error!(
509                    "RADIUS could not encode the fail-closed Access-Reject for id={}: {}",
510                    request.identifier, e
511                );
512                None
513            }
514        }
515    }
516
517    /// Structured payload for `radius_access_request`.
518    fn access_request_data(
519        request: &RadiusPacket,
520        peer_addr: SocketAddr,
521        secret: &[u8],
522    ) -> serde_json::Value {
523        let has_chap = request.first(packet::ATTR_CHAP_PASSWORD).is_some();
524        let has_eap = request.first(packet::ATTR_EAP_MESSAGE).is_some();
525
526        // PAP: unhide User-Password per RFC 2865 §5.2. A password that is not valid UTF-8
527        // is reported as hex rather than mangled through a lossy conversion, because a
528        // model told `caf?` cannot tell whether the user typed that.
529        let (auth_method, password, password_encoding) =
530            match request.first(packet::ATTR_USER_PASSWORD) {
531                Some(ciphertext) => {
532                    match packet::decode_user_password(ciphertext, &request.authenticator, secret) {
533                        Ok(bytes) => match String::from_utf8(bytes.clone()) {
534                            Ok(s) => ("pap", Some(s), "utf8"),
535                            Err(_) => ("pap", Some(hex::encode(&bytes)), "hex"),
536                        },
537                        Err(e) => {
538                            warn!("RADIUS could not decode User-Password: {}", e);
539                            ("pap", None, "utf8")
540                        }
541                    }
542                }
543                None if has_chap => ("chap", None, "utf8"),
544                None if has_eap => ("eap", None, "utf8"),
545                None => ("none", None, "utf8"),
546            };
547
548        let (state_value, state_encoding) = match request.first(packet::ATTR_STATE) {
549            Some(bytes) => match std::str::from_utf8(bytes) {
550                Ok(s) if s.chars().all(|c| !c.is_control()) => (Some(s.to_string()), "utf8"),
551                _ => (Some(hex::encode(bytes)), "hex"),
552            },
553            None => (None, "utf8"),
554        };
555
556        serde_json::json!({
557            "identifier": request.identifier,
558            "user_name": actions::text_attr(request, packet::ATTR_USER_NAME),
559            "auth_method": auth_method,
560            "password": password,
561            "password_encoding": password_encoding,
562            "nas_ip_address": actions::ip_attr(request, packet::ATTR_NAS_IP_ADDRESS),
563            "nas_identifier": actions::text_attr(request, packet::ATTR_NAS_IDENTIFIER),
564            "nas_port": actions::int_attr(request, packet::ATTR_NAS_PORT),
565            "nas_port_type": actions::int_attr(request, packet::ATTR_NAS_PORT_TYPE),
566            "calling_station_id": actions::text_attr(request, packet::ATTR_CALLING_STATION_ID),
567            "called_station_id": actions::text_attr(request, packet::ATTR_CALLED_STATION_ID),
568            "service_type": actions::int_attr(request, packet::ATTR_SERVICE_TYPE),
569            "state": state_value,
570            "state_encoding": state_encoding,
571            "source_addr": peer_addr.to_string(),
572            "attributes": actions::attributes_json(request),
573        })
574    }
575
576    /// Structured payload for `radius_accounting_request`.
577    fn accounting_request_data(request: &RadiusPacket, peer_addr: SocketAddr) -> serde_json::Value {
578        serde_json::json!({
579            "identifier": request.identifier,
580            "acct_status_type": actions::int_attr(request, packet::ATTR_ACCT_STATUS_TYPE),
581            "user_name": actions::text_attr(request, packet::ATTR_USER_NAME),
582            "acct_session_id": actions::text_attr(request, packet::ATTR_ACCT_SESSION_ID),
583            "acct_session_time": actions::int_attr(request, packet::ATTR_ACCT_SESSION_TIME),
584            "acct_input_octets": actions::int_attr(request, packet::ATTR_ACCT_INPUT_OCTETS),
585            "acct_output_octets": actions::int_attr(request, packet::ATTR_ACCT_OUTPUT_OCTETS),
586            "nas_ip_address": actions::ip_attr(request, packet::ATTR_NAS_IP_ADDRESS),
587            "source_addr": peer_addr.to_string(),
588            "attributes": actions::attributes_json(request),
589        })
590    }
591}