1pub mod actions;
18pub mod wire;
19
20pub use actions::RadiusClientProtocol;
21
22use std::collections::HashMap;
23use std::net::SocketAddr;
24use std::sync::Arc;
25use std::time::Duration;
26
27use anyhow::{anyhow, Context, Result};
28use serde_json::{json, Map, Value};
29use tokio::net::UdpSocket;
30use tokio::sync::{mpsc, oneshot};
31use tokio::time::Instant;
32use tracing::{debug, error, info, warn};
33
34use crate::client::llm_budget::call_llm_for_client;
35use crate::client::radius::actions::{
36 request_from_action, Method, RadiusRequest, RADIUS_ACCESS_ACCEPT_EVENT,
37 RADIUS_ACCESS_CHALLENGE_EVENT, RADIUS_ACCESS_REJECT_EVENT, RADIUS_ACCOUNTING_RESPONSE_EVENT,
38 RADIUS_CONNECTED_EVENT, RADIUS_ERROR_EVENT, RADIUS_STATUS_RESPONSE_EVENT, REQUEST_RESULT,
39};
40use crate::client::radius::wire::{
41 access_request, accounting_request, status_server, verify_reply, Credential, ReplyError,
42};
43use crate::llm::actions::client_trait::{Client, ClientActionResult};
44use crate::llm::actions::protocol_trait::Protocol;
45use crate::llm::ollama_client::OllamaClient;
46use crate::protocol::Event;
47use crate::server::radius::packet::{
48 attribute_info, attribute_value_json, code_name, Attribute, RadiusPacket,
49 ATTR_MESSAGE_AUTHENTICATOR, ATTR_PROXY_STATE, ATTR_REPLY_MESSAGE, ATTR_STATE,
50 CODE_ACCESS_ACCEPT, CODE_ACCESS_CHALLENGE, CODE_ACCESS_REQUEST, CODE_ACCOUNTING_REQUEST,
51 CODE_STATUS_SERVER, MAX_PACKET_LEN,
52};
53use crate::state::app_state::AppState;
54use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
55use crate::state::{AccessLogOwner, ClientId, ClientStatus};
56
57pub const MAX_IN_FLIGHT: usize = 32;
59
60const MAX_CHALLENGES: usize = 64;
62
63const TURN_QUEUE_CAPACITY: usize = 256;
64const OUTBOUND_CAPACITY: usize = 64;
65
66pub struct Settings {
68 pub secret: Vec<u8>,
69 pub accounting_port: Option<u16>,
70 pub timeout: Duration,
71 pub retries: u32,
72}
73
74enum Outbound {
75 Request {
76 request: RadiusRequest,
77 ack: oneshot::Sender<Result<usize, String>>,
78 },
79 Disconnect {
80 ack: oneshot::Sender<()>,
81 },
82}
83
84enum Applied {
85 Sent(usize),
86 Nothing,
87 Disconnect,
88}
89
90struct InFlight {
91 request: RadiusRequest,
92 code: u8,
93 target: SocketAddr,
94 packet: Vec<u8>,
95 authenticator: [u8; 16],
96 retransmissions: u32,
97 deadline: Instant,
98}
99
100pub enum Link {
104 Udp(UdpSocket),
105 Stream {
106 writer: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
107 frames: mpsc::Receiver<std::io::Result<Vec<u8>>>,
109 peer: SocketAddr,
110 },
111}
112
113impl Link {
114 fn is_udp(&self) -> bool {
115 matches!(self, Link::Udp(_))
116 }
117
118 async fn send(&mut self, packet: &[u8], target: SocketAddr) -> std::io::Result<()> {
119 use tokio::io::AsyncWriteExt;
120 match self {
121 Link::Udp(socket) => socket.send_to(packet, target).await.map(|_| ()),
122 Link::Stream { writer, .. } => {
123 writer.write_all(packet).await?;
124 writer.flush().await
125 }
126 }
127 }
128
129 async fn recv(&mut self, buf: &mut [u8]) -> std::io::Result<(Vec<u8>, SocketAddr)> {
130 match self {
131 Link::Udp(socket) => {
132 let (n, from) = socket.recv_from(buf).await?;
133 Ok((buf[..n].to_vec(), from))
134 }
135 Link::Stream { frames, peer, .. } => match frames.recv().await {
136 Some(frame) => frame.map(|f| (f, *peer)),
137 None => Err(std::io::Error::new(
138 std::io::ErrorKind::UnexpectedEof,
139 "the connection closed",
140 )),
141 },
142 }
143 }
144}
145
146pub use crate::server::radius::packet::read_frames;
147
148pub struct RadiusClient;
149
150impl RadiusClient {
151 pub async fn connect_with_llm_actions(
152 remote_addr: String,
153 settings: Settings,
154 llm_client: OllamaClient,
155 app_state: Arc<AppState>,
156 status_tx: mpsc::UnboundedSender<String>,
157 client_id: ClientId,
158 ) -> Result<SocketAddr> {
159 if remote_addr.trim().is_empty() {
160 return Err(anyhow!(
161 "RADIUS client needs the authentication server's remote_addr (host:port); \
162 refusing to start without one"
163 ));
164 }
165 let auth = tokio::net::lookup_host(&remote_addr)
166 .await
167 .with_context(|| format!("cannot resolve RADIUS server {remote_addr}"))?
168 .next()
169 .with_context(|| format!("{remote_addr} resolved to no address"))?;
170 let acct_port = match settings.accounting_port {
171 Some(p) => p,
172 None => auth.port().checked_add(1).context(
173 "the authentication port is 65535, so there is no default accounting port; set \
174 accounting_port",
175 )?,
176 };
177 let acct = SocketAddr::new(auth.ip(), acct_port);
178 let socket = UdpSocket::bind(if auth.is_ipv4() {
179 "0.0.0.0:0"
180 } else {
181 "[::]:0"
182 })
183 .await?;
184 let local_addr = socket.local_addr()?;
185 Self::run(
186 Link::Udp(socket),
187 auth,
188 acct,
189 settings,
190 llm_client,
191 app_state,
192 status_tx,
193 client_id,
194 )
195 .await;
196 Ok(local_addr)
197 }
198
199 #[allow(clippy::too_many_arguments)]
202 pub async fn run(
203 link: Link,
204 auth: SocketAddr,
205 acct: SocketAddr,
206 settings: Settings,
207 llm_client: OllamaClient,
208 app_state: Arc<AppState>,
209 status_tx: mpsc::UnboundedSender<String>,
210 client_id: ClientId,
211 ) {
212 info!("RADIUS client {client_id} ready: auth {auth}, accounting {acct}");
213 app_state
214 .update_client_status(client_id, ClientStatus::Connected)
215 .await;
216 let _ = status_tx.send(format!("[CLIENT] RADIUS client {client_id} ready"));
217 let _ = status_tx.send("__UPDATE_UI__".to_string());
218
219 let protocol = Arc::new(RadiusClientProtocol::new());
220 let (outbound_tx, outbound_rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
221 let (turn_tx, turn_rx) = mpsc::channel::<Event>(TURN_QUEUE_CAPACITY);
222
223 let command_rx =
224 crate::client::command_support::register_command_channel(&app_state, client_id).await;
225 app_state
226 .spawn_client_task(
227 client_id,
228 command_loop(
229 command_rx,
230 protocol.clone(),
231 outbound_tx.clone(),
232 client_id,
233 app_state.clone(),
234 status_tx.clone(),
235 ),
236 )
237 .await;
238 let turn_abort = app_state
239 .spawn_client_task(
240 client_id,
241 run_turns(
242 turn_rx,
243 outbound_tx,
244 protocol,
245 llm_client,
246 app_state.clone(),
247 status_tx.clone(),
248 client_id,
249 ),
250 )
251 .await;
252 let _ = turn_tx.try_send(Event::new(
253 &RADIUS_CONNECTED_EVENT,
254 json!({"auth_server": auth.to_string(), "accounting_server": acct.to_string()}),
255 ));
256 app_state
257 .spawn_client_task(
258 client_id,
259 run_transport(
260 Transport {
261 link,
262 settings,
263 auth,
264 acct,
265 pending: HashMap::new(),
266 next_id: rand::random::<u8>(),
267 challenges: HashMap::new(),
268 turn_tx,
269 client_id,
270 },
271 outbound_rx,
272 app_state.clone(),
273 status_tx,
274 turn_abort,
275 ),
276 )
277 .await;
278 }
279}
280
281fn request_name(code: u8) -> &'static str {
282 code_name(code)
283}
284
285fn user_of(request: &RadiusRequest) -> Option<&str> {
286 match request {
287 RadiusRequest::Access { user_name, .. } => Some(user_name),
288 RadiusRequest::Accounting { user_name, .. } => user_name.as_deref(),
289 RadiusRequest::Status { .. } => None,
290 }
291}
292
293fn attributes_json(packet: &RadiusPacket) -> Value {
296 let mut map = Map::new();
297 for attr in &packet.attributes {
298 if matches!(
299 attr.attr_type,
300 ATTR_MESSAGE_AUTHENTICATOR | ATTR_STATE | ATTR_PROXY_STATE
301 ) {
302 continue;
303 }
304 let (name, _) = attribute_info(attr.attr_type);
305 let name = if name == "Unknown" {
306 format!("Attribute-{}", attr.attr_type)
307 } else {
308 name.to_string()
309 };
310 let value = attribute_value_json(attr.attr_type, &attr.value);
311 match map.get_mut(&name) {
312 None => {
313 map.insert(name, value);
314 }
315 Some(Value::Array(items)) => items.push(value),
316 Some(existing) => {
317 let first = existing.take();
318 *existing = Value::Array(vec![first, value]);
319 }
320 }
321 }
322 Value::Object(map)
323}
324
325fn reply_message(packet: &RadiusPacket) -> Option<String> {
327 let parts: Vec<String> = packet
328 .all(ATTR_REPLY_MESSAGE)
329 .iter()
330 .map(|v| crate::utils::sanitize::multiline(&String::from_utf8_lossy(v)))
331 .collect();
332 (!parts.is_empty()).then(|| parts.join("\n"))
333}
334
335struct Transport {
336 link: Link,
337 settings: Settings,
338 auth: SocketAddr,
339 acct: SocketAddr,
340 pending: HashMap<u8, InFlight>,
341 next_id: u8,
342 challenges: HashMap<String, Vec<u8>>,
343 turn_tx: mpsc::Sender<Event>,
344 client_id: ClientId,
345}
346
347impl Transport {
348 fn enqueue(&self, event: Event) {
349 if self.turn_tx.try_send(event).is_err() {
350 warn!(
351 "RADIUS client {} dropped an event: the model is {TURN_QUEUE_CAPACITY} events \
352 behind decision=turn_queue_full",
353 self.client_id
354 );
355 }
356 }
357
358 fn error(&self, request: &RadiusRequest, code: u8, kind: &str, message: String) {
359 let mut data = json!({
360 "kind": kind,
361 "message": message,
362 "request": request_name(code),
363 });
364 if let Some(user) = user_of(request) {
365 data["user_name"] = json!(user);
366 }
367 self.enqueue(Event::new(&RADIUS_ERROR_EVENT, data));
368 }
369
370 fn fresh_id(&mut self) -> u8 {
371 while self.pending.contains_key(&self.next_id) {
372 self.next_id = self.next_id.wrapping_add(1);
373 }
374 let id = self.next_id;
375 self.next_id = self.next_id.wrapping_add(1);
376 id
377 }
378
379 async fn start(&mut self, request: RadiusRequest) -> Result<usize, String> {
380 if self.pending.len() >= MAX_IN_FLIGHT {
381 return Err(format!(
382 "{MAX_IN_FLIGHT} requests are already waiting for a reply"
383 ));
384 }
385 let id = self.fresh_id();
386 let secret = self.settings.secret.as_slice();
387 let (code, target, packet, authenticator) = match &request {
388 RadiusRequest::Access {
389 user_name,
390 method,
391 attributes,
392 } => {
393 let ra: [u8; 16] = rand::random();
394 let mut attrs = attributes.clone();
395 if let Some(state) = self.challenges.remove(user_name) {
397 attrs.push(Attribute::new(ATTR_STATE, state));
398 }
399 let credential = match method {
400 Method::Pap(p) => Credential::Pap(p.as_bytes()),
401 Method::Chap(p) => Credential::Chap(p.as_bytes()),
402 Method::None => Credential::None,
403 };
404 let packet = access_request(id, &ra, credential, &attrs, secret)
405 .map_err(|e| e.to_string())?;
406 (CODE_ACCESS_REQUEST, self.auth, packet, ra)
407 }
408 RadiusRequest::Accounting { attributes, .. } => {
409 let (packet, auth) =
410 accounting_request(id, attributes, secret).map_err(|e| e.to_string())?;
411 (CODE_ACCOUNTING_REQUEST, self.acct, packet, auth)
412 }
413 RadiusRequest::Status { accounting_port } => {
414 let ra: [u8; 16] = rand::random();
415 let packet = status_server(id, &ra, secret).map_err(|e| e.to_string())?;
416 let target = if *accounting_port {
417 self.acct
418 } else {
419 self.auth
420 };
421 (CODE_STATUS_SERVER, target, packet, ra)
422 }
423 };
424 self.link
425 .send(&packet, target)
426 .await
427 .map_err(|e| format!("send failed: {e}"))?;
428 debug!(
429 "RADIUS client {} sent {} id {id} to {target}",
430 self.client_id,
431 request_name(code)
432 );
433 let len = packet.len();
434 self.pending.insert(
435 id,
436 InFlight {
437 request,
438 code,
439 target,
440 packet,
441 authenticator,
442 retransmissions: 0,
443 deadline: Instant::now() + self.settings.timeout,
444 },
445 );
446 Ok(len)
447 }
448
449 async fn expire(&mut self) {
450 let now = Instant::now();
451 let due: Vec<u8> = self
452 .pending
453 .iter()
454 .filter(|(_, p)| p.deadline <= now)
455 .map(|(id, _)| *id)
456 .collect();
457 for id in due {
458 let Some(p) = self.pending.get_mut(&id) else {
459 continue;
460 };
461 if p.retransmissions < self.settings.retries {
462 p.retransmissions += 1;
463 p.deadline = now + self.settings.timeout;
464 if self.link.is_udp() {
468 let (packet, target) = (p.packet.clone(), p.target);
469 let _ = self.link.send(&packet, target).await;
470 }
471 continue;
472 }
473 let p = self.pending.remove(&id).expect("present above");
474 warn!(
475 "RADIUS client {} {} id {id} unanswered decision=timeout",
476 self.client_id,
477 request_name(p.code)
478 );
479 self.error(
480 &p.request,
481 p.code,
482 "timeout",
483 format!("no reply after {} retransmission(s)", p.retransmissions),
484 );
485 }
486 }
487
488 fn handle_datagram(&mut self, datagram: &[u8], from: SocketAddr) {
489 if datagram.len() < 2 {
490 return;
491 }
492 let id = datagram[1];
493 let Some(p) = self.pending.get(&id) else {
494 debug!(
495 "RADIUS client {} dropped a reply for identifier {id}, which is not in flight",
496 self.client_id
497 );
498 return;
499 };
500 if p.target != from {
501 warn!(
502 "RADIUS client {} dropped a reply from {from}; request {id} went to {} \
503 decision=unexpected_source",
504 self.client_id, p.target
505 );
506 return;
507 }
508 let packet = match verify_reply(datagram, p.code, &p.authenticator, &self.settings.secret) {
509 Ok(packet) => packet,
510 Err(ReplyError::Malformed(e)) => {
511 warn!(
512 "RADIUS client {} dropped a malformed reply: {e} decision=malformed",
513 self.client_id
514 );
515 return;
516 }
517 Err(e) => {
518 warn!(
522 "RADIUS client {} discarded a reply to {} id {id}: {e} decision={}",
523 self.client_id,
524 request_name(p.code),
525 e.kind()
526 );
527 let (request, code) = (p.request.clone(), p.code);
528 self.error(&request, code, e.kind(), e.to_string());
529 return;
530 }
531 };
532 let p = self.pending.remove(&id).expect("present above");
533 let attributes = attributes_json(&packet);
534 let event = match &p.request {
535 RadiusRequest::Access {
536 user_name, method, ..
537 } => {
538 let mut data = json!({
539 "user_name": user_name,
540 "method": method.name(),
541 "attributes": attributes,
542 });
543 if let Some(m) = reply_message(&packet) {
544 data["reply_message"] = json!(m);
545 }
546 match packet.code {
547 CODE_ACCESS_ACCEPT => Event::new(&RADIUS_ACCESS_ACCEPT_EVENT, data),
548 CODE_ACCESS_CHALLENGE => {
549 if let Some(state) = packet.first(ATTR_STATE) {
550 if self.challenges.len() >= MAX_CHALLENGES {
551 self.challenges.clear();
552 }
553 self.challenges.insert(user_name.clone(), state.to_vec());
554 }
555 Event::new(&RADIUS_ACCESS_CHALLENGE_EVENT, data)
556 }
557 _ => Event::new(&RADIUS_ACCESS_REJECT_EVENT, data),
558 }
559 }
560 RadiusRequest::Accounting {
561 status_type,
562 session_id,
563 ..
564 } => Event::new(
565 &RADIUS_ACCOUNTING_RESPONSE_EVENT,
566 json!({
567 "status_type": status_type,
568 "session_id": session_id,
569 "attributes": attributes,
570 }),
571 ),
572 RadiusRequest::Status { accounting_port } => Event::new(
573 &RADIUS_STATUS_RESPONSE_EVENT,
574 json!({
575 "port": if *accounting_port { "accounting" } else { "auth" },
576 "code": code_name(packet.code),
577 "attributes": attributes,
578 }),
579 ),
580 };
581 info!(
582 "RADIUS client {} {} id {id} answered {}",
583 self.client_id,
584 request_name(p.code),
585 code_name(packet.code)
586 );
587 self.enqueue(event);
588 }
589}
590
591async fn run_transport(
593 mut t: Transport,
594 mut outbound_rx: mpsc::Receiver<Outbound>,
595 app_state: Arc<AppState>,
596 status_tx: mpsc::UnboundedSender<String>,
597 turn_abort: tokio::task::AbortHandle,
598) {
599 let client_id = t.client_id;
600 let mut buf = vec![0u8; MAX_PACKET_LEN + 1];
604 let status = loop {
605 let deadline = t.pending.values().map(|p| p.deadline).min();
606 tokio::select! {
607 received = t.link.recv(&mut buf) => match received {
608 Ok((datagram, from)) => {
609 t.handle_datagram(&datagram, from);
610 }
611 Err(e) if t.link.is_udp() && (e.kind() == std::io::ErrorKind::ConnectionRefused
612 || e.kind() == std::io::ErrorKind::ConnectionReset) => {
613 debug!("RADIUS client {client_id}: {e}");
614 }
615 Err(e) => {
616 error!("RADIUS client {client_id} socket error: {e}");
617 break ClientStatus::Error(e.to_string());
618 }
619 },
620 _ = async {
621 match deadline {
622 Some(d) => tokio::time::sleep_until(d).await,
623 None => std::future::pending::<()>().await,
624 }
625 } => t.expire().await,
626 out = outbound_rx.recv() => match out {
627 None => break ClientStatus::Disconnected,
628 Some(Outbound::Disconnect { ack }) => {
629 let _ = ack.send(());
630 info!("RADIUS client {client_id} stopped on request");
631 break ClientStatus::Disconnected;
632 }
633 Some(Outbound::Request { request, ack }) => {
634 let _ = ack.send(t.start(request).await);
635 }
636 },
637 }
638 };
639
640 turn_abort.abort();
641 app_state.remove_client_handle(client_id).await;
642 app_state.update_client_status(client_id, status).await;
643 let _ = status_tx.send(format!("[CLIENT] RADIUS client {client_id} stopped"));
644 let _ = status_tx.send("__UPDATE_UI__".to_string());
645}
646
647async fn run_turns(
649 mut turn_rx: mpsc::Receiver<Event>,
650 outbound_tx: mpsc::Sender<Outbound>,
651 protocol: Arc<RadiusClientProtocol>,
652 llm_client: OllamaClient,
653 app_state: Arc<AppState>,
654 status_tx: mpsc::UnboundedSender<String>,
655 client_id: ClientId,
656) {
657 while let Some(event) = turn_rx.recv().await {
658 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
659 continue;
660 };
661 let memory = app_state
662 .get_memory_for_client(client_id)
663 .await
664 .unwrap_or_default();
665 match call_llm_for_client(
666 &llm_client,
667 &app_state,
668 client_id.to_string(),
669 &instruction,
670 &memory,
671 Some(&event),
672 protocol.as_ref(),
673 &status_tx,
674 )
675 .await
676 {
677 Ok(result) => {
678 if let Some(mem) = result.memory_updates {
679 app_state.set_memory_for_client(client_id, mem).await;
680 }
681 info!(
682 "RADIUS client {client_id} {} decision={} actions={}",
683 event.id(),
684 if result.actions.is_empty() {
685 "model_silent"
686 } else {
687 "model_actions"
688 },
689 result.actions.len()
690 );
691 for action in result.actions {
692 match apply_action(protocol.as_ref(), &outbound_tx, action).await {
693 Ok(Applied::Disconnect) => return,
694 Ok(_) => {}
695 Err(e) => {
696 error!("RADIUS client {client_id} action failed: {e}");
697 let _ = status_tx.send(format!(
698 "[ERROR] RADIUS client {client_id} action failed: {e}"
699 ));
700 }
701 }
702 }
703 }
704 Err(e) => error!(
705 "RADIUS client {client_id} {} decision=llm_error: {e}",
706 event.id()
707 ),
708 }
709 }
710}
711
712async fn apply_action(
715 protocol: &RadiusClientProtocol,
716 outbound_tx: &mpsc::Sender<Outbound>,
717 action: Value,
718) -> Result<Applied> {
719 match protocol.execute_action(action)? {
720 ClientActionResult::Custom { name, data } if name == REQUEST_RESULT => {
721 let request = request_from_action(&data)?
722 .ok_or_else(|| anyhow!("a request action produced no request"))?;
723 let (ack, done) = oneshot::channel();
724 outbound_tx
725 .send(Outbound::Request { request, ack })
726 .await
727 .map_err(|_| anyhow!("the client is stopped"))?;
728 let bytes = done
729 .await
730 .map_err(|_| anyhow!("the client stopped before the request was sent"))?
731 .map_err(|e| anyhow!(e))?;
732 Ok(Applied::Sent(bytes))
733 }
734 ClientActionResult::Disconnect => {
735 let (ack, done) = oneshot::channel();
736 if outbound_tx.send(Outbound::Disconnect { ack }).await.is_ok() {
737 let _ = done.await;
738 }
739 Ok(Applied::Disconnect)
740 }
741 _ => Ok(Applied::Nothing),
742 }
743}
744
745async fn command_loop(
746 mut command_rx: mpsc::Receiver<ClientCommand>,
747 protocol: Arc<RadiusClientProtocol>,
748 outbound_tx: mpsc::Sender<Outbound>,
749 client_id: ClientId,
750 app_state: Arc<AppState>,
751 status_tx: mpsc::UnboundedSender<String>,
752) {
753 while let Some(command) = command_rx.recv().await {
754 let action = command.action.clone();
755 let outcome = match apply_action(protocol.as_ref(), &outbound_tx, action.clone()).await {
756 Ok(Applied::Sent(bytes_sent)) => ClientSendOutcome::Sent { bytes_sent },
757 Ok(Applied::Nothing) => ClientSendOutcome::Executed {
758 detail: "executed (nothing to send)".to_string(),
759 },
760 Ok(Applied::Disconnect) => ClientSendOutcome::Disconnected,
761 Err(e) => ClientSendOutcome::Rejected {
762 error: e.to_string(),
763 },
764 };
765 let mut logged = action;
768 if logged.get("password").is_some() {
769 logged["password"] = json!("<redacted>");
770 }
771 app_state
772 .record_access_log(
773 AccessLogOwner::Client(client_id.as_u32()),
774 protocol.protocol_name(),
775 None,
776 "injected_action",
777 logged,
778 vec![serde_json::to_value(&outcome).unwrap_or_default()],
779 )
780 .await;
781 let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
782 let _ = status_tx.send("__UPDATE_UI__".to_string());
783 crate::client::command_support::reply(command, Ok(outcome));
784 if disconnect {
785 break;
786 }
787 }
788}