1use 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
26pub const REQUEST_RESULT: &str = "radius_request";
28
29pub const DEFAULT_TIMEOUT_MS: u64 = 3000;
31
32pub const DEFAULT_RETRIES: u64 = 2;
34
35pub 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
227pub 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#[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#[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
386fn 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
402fn attribute_type(name: &str) -> Option<u8> {
404 (1..=255u8).find(|t| attribute_info(*t).0.eq_ignore_ascii_case(name))
405}
406
407const 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
509pub 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 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}