Skip to main content

netget/client/radius/
actions.rs

1//! RADIUS client actions, events and metadata.
2//!
3//! NetGet is the NAS: it sends Access-Request (PAP or CHAP), Accounting-Request and
4//! Status-Server to a RADIUS server, and the model decides who to authenticate and what to
5//! account. The shared secret is a startup parameter the transport uses for every
6//! authenticator; it is in no action, no event and no log line.
7
8use crate::llm::actions::{
9    client_trait::{Client, ClientActionResult},
10    protocol_trait::Protocol,
11    ActionDefinition, Parameter, ParameterDefinition,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::server::radius::packet::{
16    attribute_info, AttrKind, Attribute, ATTR_ACCT_SESSION_ID, ATTR_ACCT_STATUS_TYPE,
17    ATTR_CHAP_PASSWORD, ATTR_EAP_MESSAGE, ATTR_MESSAGE_AUTHENTICATOR, ATTR_PROXY_STATE, ATTR_STATE,
18    ATTR_USER_NAME, ATTR_USER_PASSWORD, MAX_USER_PASSWORD_LEN,
19};
20use crate::state::app_state::AppState;
21use anyhow::{anyhow, Context, Result};
22use serde_json::{json, Value};
23use std::net::Ipv4Addr;
24use std::sync::LazyLock;
25
26/// The name of the `ClientActionResult::Custom` every request travels as.
27pub const REQUEST_RESULT: &str = "radius_request";
28
29/// Milliseconds to wait for a reply before retransmitting.
30pub const DEFAULT_TIMEOUT_MS: u64 = 3000;
31
32/// Retransmissions of an unanswered request before it is reported as a timeout.
33pub const DEFAULT_RETRIES: u64 = 2;
34
35/// NAS-Identifier sent on every request unless the action names another.
36pub const DEFAULT_NAS_IDENTIFIER: &str = "netget";
37
38fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
39    Parameter {
40        name: name.to_string(),
41        type_hint: type_hint.to_string(),
42        description: description.to_string(),
43        required,
44    }
45}
46
47fn attributes_param() -> Parameter {
48    param(
49        "attributes",
50        "object",
51        "Every other attribute of the reply, by dictionary name (Reply-Message, Session-Timeout, \
52         Framed-IP-Address, Class, …); integers as numbers, addresses as dotted quads, opaque \
53         values as hex",
54        true,
55    )
56}
57
58pub static RADIUS_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59    EventType::new(
60        "radius_connected",
61        "Ready to send RADIUS requests (UDP has no handshake; nothing was sent)",
62        json!({
63            "type": "radius_access_request",
64            "user_name": "alice",
65            "password": "wonderland"
66        }),
67    )
68    .with_parameters(vec![
69        param(
70            "auth_server",
71            "string",
72            "Where Access-Request and Status-Server go",
73            true,
74        ),
75        param(
76            "accounting_server",
77            "string",
78            "Where Accounting-Request goes",
79            true,
80        ),
81    ])
82});
83
84fn access_reply_params() -> Vec<Parameter> {
85    vec![
86        param(
87            "user_name",
88            "string",
89            "The User-Name the request carried",
90            true,
91        ),
92        param(
93            "method",
94            "string",
95            "pap, chap or none — how the password was sent",
96            true,
97        ),
98        param(
99            "reply_message",
100            "string",
101            "The server's Reply-Message, if it sent one",
102            false,
103        ),
104        attributes_param(),
105    ]
106}
107
108pub static RADIUS_ACCESS_ACCEPT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
109    EventType::new(
110        "radius_access_accept",
111        "The server accepted the user (Access-Accept, authenticators verified)",
112        json!({
113            "type": "radius_accounting_request",
114            "status_type": "Start",
115            "session_id": "s-1",
116            "user_name": "alice"
117        }),
118    )
119    .with_parameters(access_reply_params())
120});
121
122pub static RADIUS_ACCESS_REJECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
123    EventType::new(
124        "radius_access_reject",
125        "The server refused the user (Access-Reject, authenticators verified)",
126        json!({"type": "disconnect"}),
127    )
128    .with_parameters(access_reply_params())
129});
130
131pub static RADIUS_ACCESS_CHALLENGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
132    EventType::new(
133        "radius_access_challenge",
134        "The server wants another round (Access-Challenge). Answer with radius_access_request \
135         for the same user: the client carries the challenge's State back itself",
136        json!({
137            "type": "radius_access_request",
138            "user_name": "alice",
139            "password": "123456"
140        }),
141    )
142    .with_parameters(access_reply_params())
143});
144
145pub static RADIUS_ACCOUNTING_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
146    EventType::new(
147        "radius_accounting_response",
148        "The server acknowledged an Accounting-Request (Accounting-Response, verified)",
149        json!({
150            "type": "radius_accounting_request",
151            "status_type": "Stop",
152            "session_id": "s-1"
153        }),
154    )
155    .with_parameters(vec![
156        param(
157            "status_type",
158            "string",
159            "The Acct-Status-Type acknowledged",
160            true,
161        ),
162        param(
163            "session_id",
164            "string",
165            "The Acct-Session-Id acknowledged",
166            true,
167        ),
168        attributes_param(),
169    ])
170});
171
172pub static RADIUS_STATUS_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
173    EventType::new(
174        "radius_status_response",
175        "The server answered a Status-Server: it is up and holds the shared secret",
176        json!({"type": "radius_access_request", "user_name": "alice", "password": "x"}),
177    )
178    .with_parameters(vec![
179        param(
180            "port",
181            "string",
182            "auth or accounting — which server answered",
183            true,
184        ),
185        param(
186            "code",
187            "string",
188            "Access-Accept or Accounting-Response",
189            true,
190        ),
191        attributes_param(),
192    ])
193});
194
195pub static RADIUS_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
196    EventType::new(
197        "radius_error",
198        "A request got no trustworthy answer",
199        json!({"type": "radius_status_server"}),
200    )
201    .with_parameters(vec![
202        param(
203            "kind",
204            "string",
205            "timeout (no reply after every retransmission), bad_authenticator or \
206             bad_message_authenticator (a reply that does not prove it knows the secret; it was \
207             discarded and the request is still waiting), missing_message_authenticator, \
208             unexpected_code, or malformed",
209            true,
210        ),
211        param("message", "string", "What went wrong", true),
212        param(
213            "request",
214            "string",
215            "Access-Request, Accounting-Request or Status-Server",
216            true,
217        ),
218        param(
219            "user_name",
220            "string",
221            "The request's User-Name, if it had one",
222            false,
223        ),
224    ])
225});
226
227/// RADIUS client protocol.
228pub struct RadiusClientProtocol;
229
230impl RadiusClientProtocol {
231    pub fn new() -> Self {
232        Self
233    }
234}
235
236impl Default for RadiusClientProtocol {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242fn all_actions() -> Vec<ActionDefinition> {
243    let extra = param(
244        "attributes",
245        "object",
246        "Extra attributes by dictionary name: {\"NAS-Port\": 7, \"Called-Station-Id\": \
247         \"ap-1\", \"Framed-IP-Address\": \"10.0.0.5\"}. User-Password, CHAP-Password, \
248         Message-Authenticator, State, Proxy-State and EAP-Message are set by the client \
249         itself and refused here",
250        false,
251    );
252    vec![
253        ActionDefinition {
254            name: "radius_access_request".to_string(),
255            description: "Ask the server to authenticate a user (Access-Request)".to_string(),
256            parameters: vec![
257                param(
258                    "user_name",
259                    "string",
260                    "The user to authenticate (User-Name)",
261                    true,
262                ),
263                param("password", "string", "The user's password, as text", false),
264                param(
265                    "method",
266                    "string",
267                    "pap (default: the password hidden per RFC 2865 §5.2) or chap (only a \
268                     CHAP digest of it is sent)",
269                    false,
270                ),
271                param(
272                    "nas_identifier",
273                    "string",
274                    "NAS-Identifier (default \"netget\")",
275                    false,
276                ),
277                extra.clone(),
278            ],
279            example: json!({
280                "type": "radius_access_request",
281                "user_name": "alice",
282                "password": "wonderland"
283            }),
284            log_template: Some(
285                LogTemplate::new()
286                    .with_info("-> RADIUS Access-Request user={user_name} method={method}"),
287            ),
288        },
289        ActionDefinition {
290            name: "radius_accounting_request".to_string(),
291            description: "Report a session event to the accounting server (Accounting-Request)"
292                .to_string(),
293            parameters: vec![
294                param(
295                    "status_type",
296                    "string",
297                    "Start, Stop, Interim-Update, Accounting-On or Accounting-Off",
298                    true,
299                ),
300                param("session_id", "string", "Acct-Session-Id", true),
301                param(
302                    "user_name",
303                    "string",
304                    "The session's user (User-Name)",
305                    false,
306                ),
307                param(
308                    "nas_identifier",
309                    "string",
310                    "NAS-Identifier (default \"netget\")",
311                    false,
312                ),
313                extra,
314            ],
315            example: json!({
316                "type": "radius_accounting_request",
317                "status_type": "Start",
318                "session_id": "s-1",
319                "user_name": "alice"
320            }),
321            log_template: Some(LogTemplate::new().with_info(
322                "-> RADIUS Accounting-Request {status_type} session={session_id} user={user_name}",
323            )),
324        },
325        ActionDefinition {
326            name: "radius_status_server".to_string(),
327            description: "Ask whether the server is up and holds the shared secret \
328                          (Status-Server, RFC 5997)"
329                .to_string(),
330            parameters: vec![param(
331                "port",
332                "string",
333                "auth (default) or accounting",
334                false,
335            )],
336            example: json!({"type": "radius_status_server"}),
337            log_template: Some(LogTemplate::new().with_info("-> RADIUS Status-Server port={port}")),
338        },
339        ActionDefinition {
340            name: "disconnect".to_string(),
341            description: "Stop: forget every pending request and close the socket".to_string(),
342            parameters: vec![],
343            example: json!({"type": "disconnect"}),
344            log_template: Some(LogTemplate::new().with_info("-> RADIUS disconnect")),
345        },
346    ]
347}
348
349/// How a validated Access-Request sends its password.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum Method {
352    Pap(String),
353    Chap(String),
354    None,
355}
356
357impl Method {
358    pub fn name(&self) -> &'static str {
359        match self {
360            Method::Pap(_) => "pap",
361            Method::Chap(_) => "chap",
362            Method::None => "none",
363        }
364    }
365}
366
367/// One validated request.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum RadiusRequest {
370    Access {
371        user_name: String,
372        method: Method,
373        attributes: Vec<Attribute>,
374    },
375    Accounting {
376        status_type: String,
377        session_id: String,
378        user_name: Option<String>,
379        attributes: Vec<Attribute>,
380    },
381    Status {
382        accounting_port: bool,
383    },
384}
385
386/// Acct-Status-Type values (RFC 2866 §5.1).
387fn status_type_value(name: &str) -> Option<u32> {
388    match name
389        .to_ascii_lowercase()
390        .replace(['-', '_', ' '], "")
391        .as_str()
392    {
393        "start" => Some(1),
394        "stop" => Some(2),
395        "interimupdate" | "alive" => Some(3),
396        "accountingon" => Some(7),
397        "accountingoff" => Some(8),
398        _ => None,
399    }
400}
401
402/// The dictionary number for an attribute name, from the server's own dictionary.
403fn attribute_type(name: &str) -> Option<u8> {
404    (1..=255u8).find(|t| attribute_info(*t).0.eq_ignore_ascii_case(name))
405}
406
407/// Attributes the client sets itself. Letting the model write them would let it forge the
408/// integrity check, smuggle a password in clear, or answer a challenge it was not given.
409const RESERVED: &[u8] = &[
410    ATTR_USER_PASSWORD,
411    ATTR_CHAP_PASSWORD,
412    ATTR_MESSAGE_AUTHENTICATOR,
413    ATTR_STATE,
414    ATTR_PROXY_STATE,
415    ATTR_EAP_MESSAGE,
416];
417
418fn extra_attributes(action: &Value) -> Result<Vec<Attribute>> {
419    let Some(map) = action.get("attributes") else {
420        return Ok(Vec::new());
421    };
422    let map = match map {
423        Value::Null => return Ok(Vec::new()),
424        Value::Object(m) => m,
425        other => return Err(anyhow!("'attributes' must be an object, got {other}")),
426    };
427    let mut out = Vec::new();
428    for (name, value) in map {
429        let t =
430            attribute_type(name).with_context(|| format!("unknown RADIUS attribute {name:?}"))?;
431        if RESERVED.contains(&t)
432            || t == ATTR_USER_NAME
433            || t == ATTR_ACCT_SESSION_ID
434            || t == ATTR_ACCT_STATUS_TYPE
435        {
436            return Err(anyhow!(
437                "{name} is set by the client itself (or by its own action field), not through \
438                 'attributes'"
439            ));
440        }
441        let attr = match attribute_info(t).1 {
442            AttrKind::Text => Attribute::text(
443                t,
444                value
445                    .as_str()
446                    .with_context(|| format!("{name} is text, got {value}"))?,
447            ),
448            AttrKind::Integer => {
449                let n = value
450                    .as_u64()
451                    .with_context(|| format!("{name} is an integer, got {value}"))?;
452                Attribute::integer(
453                    t,
454                    u32::try_from(n).map_err(|_| anyhow!("{name} {n} does not fit 32 bits"))?,
455                )
456            }
457            AttrKind::IpAddr => Attribute::ipv4(
458                t,
459                value
460                    .as_str()
461                    .and_then(|s| s.parse::<Ipv4Addr>().ok())
462                    .with_context(|| format!("{name} is an IPv4 address, got {value}"))?,
463            ),
464            AttrKind::Octets => {
465                return Err(anyhow!(
466                    "{name} is an opaque octet string, which this client does not take from \
467                     an action"
468                ))
469            }
470        };
471        if attr.value.len() > 253 {
472            return Err(anyhow!(
473                "{name} is {} bytes; an attribute holds 253",
474                attr.value.len()
475            ));
476        }
477        out.push(attr);
478    }
479    Ok(out)
480}
481
482fn nas_identifier(action: &Value) -> Result<Attribute> {
483    let id = match action.get("nas_identifier") {
484        None | Some(Value::Null) => DEFAULT_NAS_IDENTIFIER,
485        Some(Value::String(s)) if !s.is_empty() && s.len() <= 253 => s.as_str(),
486        Some(other) => {
487            return Err(anyhow!(
488                "'nas_identifier' must be 1-253 bytes of text, got {other}"
489            ))
490        }
491    };
492    Ok(Attribute::text(
493        crate::server::radius::packet::ATTR_NAS_IDENTIFIER,
494        id,
495    ))
496}
497
498fn text_field(action: &Value, name: &str) -> Result<String> {
499    let s = action
500        .get(name)
501        .and_then(Value::as_str)
502        .with_context(|| format!("missing string field '{name}'"))?;
503    if s.is_empty() || s.len() > 253 {
504        return Err(anyhow!("'{name}' must be 1-253 bytes"));
505    }
506    Ok(s.to_string())
507}
508
509/// Parse one model action into a request. `Ok(None)` is `disconnect`.
510pub fn request_from_action(action: &Value) -> Result<Option<RadiusRequest>> {
511    let action_type = action
512        .get("type")
513        .and_then(Value::as_str)
514        .context("missing 'type'")?;
515    match action_type {
516        "radius_access_request" => {
517            let user_name = text_field(action, "user_name")?;
518            let password = match action.get("password") {
519                None | Some(Value::Null) => None,
520                Some(Value::String(p)) => Some(p.clone()),
521                Some(other) => return Err(anyhow!("'password' must be text, got {other}")),
522            };
523            if password
524                .as_ref()
525                .is_some_and(|p| p.len() > MAX_USER_PASSWORD_LEN)
526            {
527                return Err(anyhow!(
528                    "a RADIUS password is at most {MAX_USER_PASSWORD_LEN} bytes (RFC 2865 §5.2)"
529                ));
530            }
531            let method = match (action.get("method").and_then(Value::as_str), password) {
532                (_, None) => Method::None,
533                (None | Some("pap"), Some(p)) => Method::Pap(p),
534                (Some("chap"), Some(p)) => Method::Chap(p),
535                (Some(other), _) => return Err(anyhow!("'method' is pap or chap, got {other:?}")),
536            };
537            let mut attributes = vec![Attribute::text(ATTR_USER_NAME, &user_name)];
538            attributes.push(nas_identifier(action)?);
539            attributes.extend(extra_attributes(action)?);
540            Ok(Some(RadiusRequest::Access {
541                user_name,
542                method,
543                attributes,
544            }))
545        }
546        "radius_accounting_request" => {
547            let status_type = text_field(action, "status_type")?;
548            let value = status_type_value(&status_type).with_context(|| {
549                format!(
550                    "status_type {status_type:?} is not Start, Stop, Interim-Update, \
551                     Accounting-On or Accounting-Off"
552                )
553            })?;
554            let session_id = text_field(action, "session_id")?;
555            let user_name = match action.get("user_name") {
556                None | Some(Value::Null) => None,
557                Some(_) => Some(text_field(action, "user_name")?),
558            };
559            let mut attributes = vec![
560                Attribute::integer(ATTR_ACCT_STATUS_TYPE, value),
561                Attribute::text(ATTR_ACCT_SESSION_ID, &session_id),
562            ];
563            if let Some(u) = &user_name {
564                attributes.push(Attribute::text(ATTR_USER_NAME, u));
565            }
566            attributes.push(nas_identifier(action)?);
567            attributes.extend(extra_attributes(action)?);
568            Ok(Some(RadiusRequest::Accounting {
569                status_type,
570                session_id,
571                user_name,
572                attributes,
573            }))
574        }
575        "radius_status_server" => {
576            let accounting_port = match action.get("port").and_then(Value::as_str) {
577                None | Some("auth") => false,
578                Some("accounting") => true,
579                Some(other) => return Err(anyhow!("'port' is auth or accounting, got {other:?}")),
580            };
581            Ok(Some(RadiusRequest::Status { accounting_port }))
582        }
583        "disconnect" => Ok(None),
584        other => Err(anyhow!("Unknown RADIUS client action: {other}")),
585    }
586}
587
588impl Protocol for RadiusClientProtocol {
589    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
590        vec![
591            ParameterDefinition {
592                name: "secret".to_string(),
593                type_hint: "string".to_string(),
594                description: "The shared secret this NAS and the server both hold. Used for \
595                              every authenticator and never shown in events or logs"
596                    .to_string(),
597                required: true,
598                example: json!("testing123"),
599                default: None,
600            },
601            ParameterDefinition {
602                name: "accounting_port".to_string(),
603                type_hint: "integer".to_string(),
604                description: "UDP port of the accounting server on the same host (default: \
605                              the authentication port plus one, as 1812/1813)"
606                    .to_string(),
607                required: false,
608                example: json!(1813),
609                default: None,
610            },
611            ParameterDefinition {
612                name: "timeout_ms".to_string(),
613                type_hint: "integer".to_string(),
614                description: "Milliseconds to wait for a reply before retransmitting \
615                              (100-60000, default 3000)"
616                    .to_string(),
617                required: false,
618                example: json!(3000),
619                default: Some(json!(DEFAULT_TIMEOUT_MS)),
620            },
621            ParameterDefinition {
622                name: "retries".to_string(),
623                type_hint: "integer".to_string(),
624                description: "Retransmissions before a request is reported as a timeout \
625                              (0-5, default 2)"
626                    .to_string(),
627                required: false,
628                example: json!(2),
629                default: Some(json!(DEFAULT_RETRIES)),
630            },
631        ]
632    }
633
634    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
635        all_actions()
636    }
637
638    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
639        all_actions()
640    }
641
642    fn protocol_name(&self) -> &'static str {
643        "RADIUS"
644    }
645
646    fn get_event_types(&self) -> Vec<EventType> {
647        vec![
648            RADIUS_CONNECTED_EVENT.clone(),
649            RADIUS_ACCESS_ACCEPT_EVENT.clone(),
650            RADIUS_ACCESS_REJECT_EVENT.clone(),
651            RADIUS_ACCESS_CHALLENGE_EVENT.clone(),
652            RADIUS_ACCOUNTING_RESPONSE_EVENT.clone(),
653            RADIUS_STATUS_RESPONSE_EVENT.clone(),
654            RADIUS_ERROR_EVENT.clone(),
655        ]
656    }
657
658    fn stack_name(&self) -> &'static str {
659        "ETH>IP>UDP>RADIUS"
660    }
661
662    fn keywords(&self) -> Vec<&'static str> {
663        vec!["radius", "radius client", "nas", "aaa client"]
664    }
665
666    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
667        use crate::protocol::metadata::{
668            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
669        };
670
671        ProtocolMetadataV2::builder()
672            .state(DevelopmentState::Beta)
673            .privilege_requirement(PrivilegeRequirement::None)
674            .implementation(
675                "RADIUS NAS (RFC 2865/2866/5997) over UDP, building packets with the server's \
676                 codec (src/server/radius/packet.rs) plus src/client/radius/wire.rs: PAP \
677                 User-Password hiding, CHAP-Password, the Accounting-Request authenticator, and \
678                 a Message-Authenticator (RFC 3579) on every Access-Request and Status-Server. \
679                 Every reply is checked before the model sees it: its code against the \
680                 request, its Response Authenticator, and its Message-Authenticator, which is \
681                 required on access and status replies. A transport task owns the socket, the \
682                 identifiers and retransmission; the model is asked from a separate turn task.",
683            )
684            .llm_control(
685                "Who to authenticate and how (PAP or CHAP), what to account (Start, Stop, \
686                 Interim-Update, Accounting-On/Off with extra attributes by dictionary name), \
687                 and Status-Server probes. The shared secret is a startup parameter the model \
688                 never sees again.",
689            )
690            .e2e_testing(
691                "tests/client/radius/real_server_test.rs, 8 LLM calls, against FreeRADIUS \
692                 radiusd -X run unprivileged from a minimal raddb that requires a \
693                 Message-Authenticator. PAP and CHAP are accepted with the users file's \
694                 Reply-Message, a wrong password and an always-reject user are refused, an \
695                 Accounting Start carrying the model's attributes lands in FreeRADIUS's detail \
696                 file, and Status-Server is answered; FreeRADIUS checks every authenticator \
697                 NetGet computes and NetGet verifies every reply's. The test also asserts the \
698                 shared secret reaches no event and nothing NetGet printed. A second test \
699                 injects an Access-Request (logged by FreeRADIUS as Login OK) and an \
700                 Accounting Stop through the command channel. Not #[ignore]d; a missing \
701                 radiusd fails the test. transport_test.rs refuses forged and unsigned replies \
702                 against hand-written servers.",
703            )
704            .notes(
705                "No EAP, MS-CHAP, CoA/Disconnect-Request (RFC 5176), RadSec or IPv6 attributes. \
706                 A challenge's State is carried back automatically on the next Access-Request \
707                 for the same user; that path is not exercised against a real server.",
708            )
709            .build()
710    }
711
712    fn description(&self) -> &'static str {
713        "RADIUS client (NAS) for authentication, accounting and status checks"
714    }
715
716    fn example_prompt(&self) -> &'static str {
717        "Authenticate user alice with password wonderland against the RADIUS server at \
718         localhost:1812 with secret testing123"
719    }
720
721    fn group_name(&self) -> &'static str {
722        "Authentication"
723    }
724
725    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
726        use crate::llm::actions::StartupExamples;
727
728        StartupExamples::new(
729            json!({
730                "type": "open_client",
731                "remote_addr": "localhost:1812",
732                "base_stack": "radius",
733                "instruction": "Authenticate alice and start an accounting session for her",
734                "startup_params": {"secret": "testing123"}
735            }),
736            json!({
737                "type": "open_client",
738                "remote_addr": "localhost:1812",
739                "base_stack": "radius",
740                "startup_params": {"secret": "testing123"},
741                "event_handlers": [{
742                    "event_pattern": "radius_access_accept",
743                    "handler": {
744                        "type": "script",
745                        "language": "python",
746                        "code": "<radius_client_handler>"
747                    }
748                }]
749            }),
750            json!({
751                "type": "open_client",
752                "remote_addr": "localhost:1812",
753                "base_stack": "radius",
754                "startup_params": {"secret": "testing123"},
755                "event_handlers": [
756                    {
757                        "event_pattern": "radius_connected",
758                        "handler": {
759                            "type": "static",
760                            "actions": [{"type": "radius_status_server"}]
761                        }
762                    },
763                    {
764                        "event_pattern": "radius_status_response",
765                        "handler": {"type": "static", "actions": [{"type": "disconnect"}]}
766                    }
767                ]
768            }),
769        )
770    }
771}
772
773impl Client for RadiusClientProtocol {
774    fn connect(
775        &self,
776        ctx: crate::protocol::ConnectContext,
777    ) -> std::pin::Pin<
778        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
779    > {
780        Box::pin(async move {
781            let params = ctx.startup_params.as_ref().context(
782                "the RADIUS client needs the shared secret as startup parameter 'secret'",
783            )?;
784            let secret = params
785                .get_optional_string("secret")?
786                .filter(|s| !s.is_empty())
787                .context("the RADIUS client needs a non-empty startup parameter 'secret'")?;
788            let accounting_port = match params.get_optional_u64("accounting_port")? {
789                None => None,
790                Some(p) => Some(
791                    u16::try_from(p)
792                        .ok()
793                        .filter(|p| *p != 0)
794                        .with_context(|| format!("accounting_port {p} is not a UDP port"))?,
795                ),
796            };
797            let timeout_ms = params
798                .get_optional_u64("timeout_ms")?
799                .unwrap_or(DEFAULT_TIMEOUT_MS);
800            if !(100..=60_000).contains(&timeout_ms) {
801                return Err(anyhow!("timeout_ms {timeout_ms} is outside 100-60000"));
802            }
803            let retries = params
804                .get_optional_u64("retries")?
805                .unwrap_or(DEFAULT_RETRIES);
806            if retries > 5 {
807                return Err(anyhow!("retries {retries} is over 5"));
808            }
809            crate::client::radius::RadiusClient::connect_with_llm_actions(
810                ctx.remote_addr,
811                crate::client::radius::Settings {
812                    secret: secret.into_bytes(),
813                    accounting_port,
814                    timeout: std::time::Duration::from_millis(timeout_ms),
815                    // Range-checked above.
816                    retries: u32::try_from(retries).unwrap_or(2),
817                },
818                ctx.llm_client,
819                ctx.state,
820                ctx.status_tx,
821                ctx.client_id,
822            )
823            .await
824        })
825    }
826
827    fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
828        match request_from_action(&action)? {
829            None => Ok(ClientActionResult::Disconnect),
830            Some(_) => Ok(ClientActionResult::Custom {
831                name: REQUEST_RESULT.to_string(),
832                data: action,
833            }),
834        }
835    }
836}