1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Decision {
38 ModelAccept,
40 ModelReject,
42 ModelChallenge,
44 ModelAccountingResponse,
46 FailClosedNoAction,
48 FailClosedLlmError,
50 FailClosedActionError,
52}
53
54impl Decision {
55 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 pub fn is_fail_closed(&self) -> bool {
70 matches!(
71 self,
72 Decision::FailClosedNoAction
73 | Decision::FailClosedLlmError
74 | Decision::FailClosedActionError
75 )
76 }
77}
78
79fn 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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 fn synthesised_reject(
485 request: &RadiusPacket,
486 protocol: &actions::RadiusProtocol,
487 is_authorization: bool,
488 ) -> Option<Vec<u8>> {
489 if !is_authorization {
490 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 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 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 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 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}