Skip to main content

netget/client/radius/
mod.rs

1//! RADIUS client (a NAS): NetGet authenticates users and reports accounting to a RADIUS server,
2//! and the model decides who and what.
3//!
4//! Three tasks per client, all registered with the client:
5//!
6//! * **transport** owns the UDP socket, the shared secret, the identifiers of requests in
7//!   flight and their retransmission timers. Every reply is verified (`wire::verify_reply`)
8//!   before anything in it reaches the model; one that does not verify is discarded and reported
9//!   as `radius_error`, and the request goes on waiting for a genuine answer.
10//! * **turns** asks the model about each reply or error, in order, and hands its actions back
11//!   to the transport. The chain passes through that queue, so it needs no recursion.
12//! * **commands** runs injected actions (`[ send ]`, MCP `send_to_client`) down the same path.
13//!
14//! The shared secret lives in the transport's `Settings` and is used for nothing but
15//! authenticators. `Settings` deliberately does not implement `Debug`.
16
17pub 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
57/// Requests awaiting a reply. The next is refused; the identifier space is 256.
58pub const MAX_IN_FLIGHT: usize = 32;
59
60/// Challenge `State`s remembered for the next Access-Request of the same user.
61const MAX_CHALLENGES: usize = 64;
62
63const TURN_QUEUE_CAPACITY: usize = 256;
64const OUTBOUND_CAPACITY: usize = 64;
65
66/// What the transport needs, secret included. Not `Debug`, so it cannot be logged by accident.
67pub 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
100/// Where packets go: a UDP socket (RADIUS) or a framed stream (RadSec, RFC 6614, which carries
101/// the same packets over TLS). On a stream a request is never retransmitted — the transport is
102/// reliable — but it waits the same total time before it is reported unanswered.
103pub enum Link {
104    Udp(UdpSocket),
105    Stream {
106        writer: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
107        /// Whole packets, read by a separate registered task (see [`read_frames`]).
108        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    /// Start the client's three tasks over `link`. `auth` and `acct` are where each kind of
200    /// request goes; on a stream both are its peer.
201    #[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
293/// A reply's attributes by dictionary name, minus the ones that only mean something to the
294/// transport (Message-Authenticator, State, Proxy-State). A repeated attribute becomes an array.
295fn 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
325/// Reply-Message, all of them joined, as the peer's multi-line text.
326fn 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                // Answering a challenge: carry its State back (RFC 2865 §5.24).
396                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                // RFC 2865 §2.5: a retransmission is the identical packet, same identifier and
465                // authenticator. On a stream nothing is resent (RFC 6614 §2.5): the request
466                // just waits the same total time.
467                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                // Discarded (RFC 2865 §3: an invalid Response Authenticator is silently
519                // discarded) — the request keeps waiting for a genuine reply — and reported,
520                // because a reply that does not verify is exactly what an operator must see.
521                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
591/// Own the socket for the life of the client.
592async 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    // One byte over the RFC 2865 maximum, so an oversize datagram reaches the shared decoder
601    // as oversize (`RadiusPacket::decode` refuses past 4096) rather than silently truncated to
602    // something that decodes.
603    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
647/// Answer queued events with the model, one at a time, in arrival order.
648async 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
712/// Execute one action and hand its request to the transport. Shared by the model's turns and
713/// injected commands.
714async 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        // A password the operator typed is not the shared secret, but it is not something to
766        // keep in an access log either.
767        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}