1use crate::llm::actions::{
26 protocol_trait::{ActionResult, Protocol, Server},
27 ActionDefinition, Parameter, ParameterDefinition,
28};
29use crate::protocol::EventType;
30use crate::state::app_state::AppState;
31use anyhow::{Context, Result};
32use serde_json::json;
33use std::net::Ipv4Addr;
34use std::sync::LazyLock;
35
36use super::packet::{self, Attribute, RadiusPacket};
37
38pub struct RadiusProtocol {
45 request: Option<RequestContext>,
46}
47
48#[derive(Clone)]
50pub struct RequestContext {
51 pub identifier: u8,
53 pub authenticator: [u8; 16],
55 pub secret: Vec<u8>,
57 pub proxy_state: Vec<Vec<u8>>,
59}
60
61impl Default for RadiusProtocol {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl RadiusProtocol {
68 pub fn new() -> Self {
69 Self { request: None }
70 }
71
72 pub fn for_request(ctx: RequestContext) -> Self {
73 Self { request: Some(ctx) }
74 }
75
76 fn ctx(&self) -> Result<&RequestContext> {
77 self.request.as_ref().context(
78 "RADIUS action executed without a request context: the reply's Response \
79 Authenticator cannot be computed. This instance came from the registry, not \
80 from a received packet.",
81 )
82 }
83
84 pub fn proxy_state(&self) -> &[Vec<u8>] {
86 match &self.request {
87 Some(ctx) => &ctx.proxy_state,
88 None => &[],
89 }
90 }
91
92 pub fn encode_reply(&self, code: u8, attributes: &[Attribute]) -> Result<Vec<u8>> {
97 let ctx = self.ctx()?;
98 let encode = if matches!(
99 code,
100 packet::CODE_ACCESS_ACCEPT | packet::CODE_ACCESS_REJECT | packet::CODE_ACCESS_CHALLENGE
101 ) {
102 packet::encode_signed_response
103 } else {
104 packet::encode_response
105 };
106 encode(
107 code,
108 ctx.identifier,
109 attributes,
110 &ctx.authenticator,
111 &ctx.secret,
112 )
113 .map_err(|e| anyhow::anyhow!("Failed to encode RADIUS {}: {}", packet::code_name(code), e))
114 }
115
116 fn reply(&self, code: u8, mut attributes: Vec<Attribute>) -> Result<ActionResult> {
118 for state in self.proxy_state() {
119 attributes.push(Attribute::new(packet::ATTR_PROXY_STATE, state.clone()));
120 }
121 Ok(ActionResult::Output(self.encode_reply(code, &attributes)?))
122 }
123}
124
125fn service_type_value(v: &serde_json::Value) -> Result<u32> {
132 if let Some(n) = v.as_u64() {
133 return u32::try_from(n).context("service_type out of range");
134 }
135 let name = v
136 .as_str()
137 .context("service_type must be a number or a well-known name")?;
138 let n = match name
139 .to_ascii_lowercase()
140 .replace(['-', '_', ' '], "")
141 .as_str()
142 {
143 "login" | "loginuser" => 1,
144 "framed" | "frameduser" => 2,
145 "callbacklogin" | "callbackloginuser" => 3,
146 "callbackframed" | "callbackframeduser" => 4,
147 "outbound" | "outbounduser" => 5,
148 "administrative" | "administrativeuser" => 6,
149 "nasprompt" | "naspromptuser" => 7,
150 "authenticateonly" => 8,
151 "callbacknasprompt" => 9,
152 "callcheck" => 10,
153 "callbackadministrative" => 11,
154 other => {
155 return Err(anyhow::anyhow!(
156 "Unknown service_type '{}'. Use a number, or one of: Login-User, \
157 Framed-User, Callback-Login-User, Callback-Framed-User, Outbound-User, \
158 Administrative-User, NAS-Prompt-User, Authenticate-Only, \
159 Callback-NAS-Prompt, Call-Check, Callback-Administrative",
160 other
161 ))
162 }
163 };
164 Ok(n)
165}
166
167fn framed_protocol_value(v: &serde_json::Value) -> Result<u32> {
169 if let Some(n) = v.as_u64() {
170 return u32::try_from(n).context("framed_protocol out of range");
171 }
172 let name = v
173 .as_str()
174 .context("framed_protocol must be a number or a well-known name")?;
175 let n = match name.to_ascii_lowercase().as_str() {
176 "ppp" => 1,
177 "slip" => 2,
178 "ara" => 3,
179 "gandalf" => 4,
180 "xylogics" => 5,
181 "x75" | "x.75" => 6,
182 other => {
183 return Err(anyhow::anyhow!(
184 "Unknown framed_protocol '{}'. Use a number, or one of: PPP, SLIP, ARA, \
185 Gandalf, Xylogics, X.75",
186 other
187 ))
188 }
189 };
190 Ok(n)
191}
192
193fn decode_encoded_field(
199 action: &serde_json::Value,
200 value_key: &str,
201 encoding_key: &str,
202) -> Result<Option<Vec<u8>>> {
203 let raw = match action.get(value_key).and_then(|v| v.as_str()) {
204 Some(s) => s,
205 None => return Ok(None),
206 };
207 let encoding = action
208 .get(encoding_key)
209 .and_then(|v| v.as_str())
210 .unwrap_or("utf8");
211 match encoding {
212 "utf8" => Ok(Some(raw.as_bytes().to_vec())),
213 "hex" => Ok(Some(hex::decode(raw).with_context(|| {
214 format!("'{}' is declared hex but is not valid hex", value_key)
215 })?)),
216 other => Err(anyhow::anyhow!(
217 "Unknown {} '{}': use \"utf8\" or \"hex\"",
218 encoding_key,
219 other
220 )),
221 }
222}
223
224fn push_reply_message(attributes: &mut Vec<Attribute>, text: &str) {
227 let bytes = text.as_bytes();
228 if bytes.is_empty() {
229 return;
230 }
231 for chunk in bytes.chunks(253) {
232 attributes.push(Attribute::new(packet::ATTR_REPLY_MESSAGE, chunk.to_vec()));
233 }
234}
235
236fn push_optional_reply_message(attributes: &mut Vec<Attribute>, action: &serde_json::Value) {
237 match action.get("reply_message") {
238 Some(serde_json::Value::String(s)) => push_reply_message(attributes, s),
239 Some(serde_json::Value::Array(items)) => {
240 for item in items {
241 if let Some(s) = item.as_str() {
242 push_reply_message(attributes, s);
243 }
244 }
245 }
246 _ => {}
247 }
248}
249
250fn parse_ipv4(action: &serde_json::Value, key: &str) -> Result<Option<Ipv4Addr>> {
251 match action.get(key).and_then(|v| v.as_str()) {
252 Some(s) => Ok(Some(s.parse::<Ipv4Addr>().with_context(|| {
253 format!("'{}' is not a valid IPv4 address: {}", key, s)
254 })?)),
255 None => Ok(None),
256 }
257}
258
259fn parse_u32(action: &serde_json::Value, key: &str) -> Result<Option<u32>> {
260 match action.get(key) {
261 None | Some(serde_json::Value::Null) => Ok(None),
262 Some(v) => {
263 let n = v
264 .as_u64()
265 .with_context(|| format!("'{}' must be a non-negative integer", key))?;
266 Ok(Some(
267 u32::try_from(n).with_context(|| format!("'{}' exceeds 32 bits", key))?,
268 ))
269 }
270 }
271}
272
273fn encode_vendor_specific(entry: &serde_json::Value) -> Result<Attribute> {
276 let vendor_id = entry
277 .get("vendor_id")
278 .and_then(|v| v.as_u64())
279 .context("vendor attribute needs a numeric 'vendor_id'")?;
280 let vendor_id = u32::try_from(vendor_id).context("vendor_id exceeds 32 bits")?;
281 let vendor_type = entry
282 .get("vendor_type")
283 .and_then(|v| v.as_u64())
284 .context("vendor attribute needs a numeric 'vendor_type' (0-255)")?;
285 let vendor_type = u8::try_from(vendor_type).context("vendor_type must be 0-255")?;
286
287 let value = decode_encoded_field(entry, "value", "value_encoding")?
288 .context("vendor attribute needs a 'value'")?;
289 if value.len() > 247 {
290 return Err(anyhow::anyhow!(
291 "vendor attribute value is {} bytes; the RFC 2865 §5.26 layout allows at most 247",
292 value.len()
293 ));
294 }
295
296 let mut out = Vec::with_capacity(6 + value.len());
297 out.extend_from_slice(&vendor_id.to_be_bytes());
298 out.push(vendor_type);
299 out.push((value.len() + 2) as u8);
300 out.extend_from_slice(&value);
301 Ok(Attribute::new(26, out))
302}
303
304fn push_vendor_attributes(
305 attributes: &mut Vec<Attribute>,
306 action: &serde_json::Value,
307) -> Result<()> {
308 if let Some(items) = action.get("vendor_attributes").and_then(|v| v.as_array()) {
309 for entry in items {
310 attributes.push(encode_vendor_specific(entry)?);
311 }
312 }
313 Ok(())
314}
315
316pub static RADIUS_ACCESS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
321 EventType::new(
322 "radius_access_request",
323 "A NAS asked whether to admit a user. Decide: accept, reject, or challenge. \
324 There is no default — if you return nothing, the server sends Access-Reject.",
325 json!({
326 "type": "send_access_accept",
327 "reply_message": "Welcome",
328 "session_timeout": 3600
329 }),
330 )
331 .with_parameters(vec![
332 p(
333 "identifier",
334 "number",
335 "RADIUS packet identifier (echoed automatically)",
336 ),
337 p(
338 "user_name",
339 "string",
340 "User-Name attribute, or null if absent",
341 ),
342 p(
343 "auth_method",
344 "string",
345 "How the user authenticated: 'pap' (User-Password present and decrypted), \
346 'chap' (CHAP-Password present, NOT validated by this server), 'eap' \
347 (EAP-Message present, NOT processed), or 'none'",
348 ),
349 p(
350 "password",
351 "string",
352 "Cleartext password, present only when auth_method is 'pap'. Decrypted from \
353 User-Password per RFC 2865 §5.2 with the shared secret",
354 ),
355 p("nas_ip_address", "string", "NAS-IP-Address, or null"),
356 p("nas_identifier", "string", "NAS-Identifier, or null"),
357 p("nas_port", "number", "NAS-Port, or null"),
358 p("nas_port_type", "number", "NAS-Port-Type, or null"),
359 p(
360 "calling_station_id",
361 "string",
362 "Calling-Station-Id (client MAC/number), or null",
363 ),
364 p(
365 "called_station_id",
366 "string",
367 "Called-Station-Id (SSID/NAS number), or null",
368 ),
369 p("service_type", "number", "Service-Type, or null"),
370 p(
371 "state",
372 "string",
373 "State attribute echoed back by the NAS after a previous Access-Challenge, \
374 or null on a first request",
375 ),
376 p(
377 "state_encoding",
378 "string",
379 "'utf8' or 'hex' — how to read the state field",
380 ),
381 p("source_addr", "string", "Address the request came from"),
382 p(
383 "attributes",
384 "array",
385 "Every attribute in the request as {type, name, value}, including ones not \
386 broken out above",
387 ),
388 ])
389 .with_actions(vec![
390 send_access_accept_action(),
391 send_access_reject_action(),
392 send_access_challenge_action(),
393 ])
394});
395
396pub static RADIUS_ACCOUNTING_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
397 EventType::new(
398 "radius_accounting_request",
399 "A NAS reported session accounting (RFC 2866). Acknowledge it, or stay silent to \
400 make the NAS retry.",
401 json!({ "type": "send_accounting_response" }),
402 )
403 .with_parameters(vec![
404 p(
405 "identifier",
406 "number",
407 "RADIUS packet identifier (echoed automatically)",
408 ),
409 p(
410 "acct_status_type",
411 "number",
412 "Acct-Status-Type: 1=Start, 2=Stop, 3=Interim-Update, 7=Accounting-On, \
413 8=Accounting-Off",
414 ),
415 p("user_name", "string", "User-Name, or null"),
416 p("acct_session_id", "string", "Acct-Session-Id, or null"),
417 p(
418 "acct_session_time",
419 "number",
420 "Seconds the session has run, or null",
421 ),
422 p(
423 "acct_input_octets",
424 "number",
425 "Octets received from the user, or null",
426 ),
427 p(
428 "acct_output_octets",
429 "number",
430 "Octets sent to the user, or null",
431 ),
432 p("nas_ip_address", "string", "NAS-IP-Address, or null"),
433 p("source_addr", "string", "Address the request came from"),
434 p(
435 "attributes",
436 "array",
437 "Every attribute as {type, name, value}",
438 ),
439 ])
440 .with_actions(vec![send_accounting_response_action()])
441});
442
443pub static RADIUS_STATUS_SERVER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
444 EventType::new(
445 "radius_status_server",
446 "A NAS probed whether this server is alive (Status-Server, RFC 5997). Answer with \
447 Access-Accept to report healthy, or Access-Reject to report unhealthy.",
448 json!({ "type": "send_access_accept", "reply_message": "alive" }),
449 )
450 .with_parameters(vec![
451 p(
452 "identifier",
453 "number",
454 "RADIUS packet identifier (echoed automatically)",
455 ),
456 p("source_addr", "string", "Address the probe came from"),
457 p(
458 "attributes",
459 "array",
460 "Every attribute as {type, name, value}",
461 ),
462 ])
463 .with_actions(vec![
464 send_access_accept_action(),
465 send_access_reject_action(),
466 ])
467});
468
469fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
470 Parameter {
471 name: name.to_string(),
472 type_hint: type_hint.to_string(),
473 description: description.to_string(),
474 required: false,
475 }
476}
477
478fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
479 Parameter {
480 name: name.to_string(),
481 type_hint: type_hint.to_string(),
482 description: description.to_string(),
483 required: true,
484 }
485}
486
487fn send_access_accept_action() -> ActionDefinition {
492 ActionDefinition {
493 name: "send_access_accept".to_string(),
494 description: "GRANT access. Sends Access-Request → Access-Accept with the reply \
495 attributes given here. Use this only when the credentials and policy \
496 genuinely check out."
497 .to_string(),
498 parameters: vec![
499 p(
500 "reply_message",
501 "string|array",
502 "Text shown to the user (Reply-Message). May be an array for several lines",
503 ),
504 p(
505 "framed_ip_address",
506 "string",
507 "Framed-IP-Address to assign, e.g. '10.0.0.42'",
508 ),
509 p(
510 "framed_ip_netmask",
511 "string",
512 "Framed-IP-Netmask, e.g. '255.255.255.0'",
513 ),
514 p(
515 "framed_protocol",
516 "number|string",
517 "Framed-Protocol: a number, or 'PPP'/'SLIP'",
518 ),
519 p("framed_mtu", "number", "Framed-MTU in octets"),
520 p(
521 "service_type",
522 "number|string",
523 "Service-Type: a number, or e.g. 'Framed-User'",
524 ),
525 p("session_timeout", "number", "Session-Timeout in seconds"),
526 p("idle_timeout", "number", "Idle-Timeout in seconds"),
527 p(
528 "filter_id",
529 "string",
530 "Filter-Id naming an access filter on the NAS",
531 ),
532 p(
533 "class",
534 "string",
535 "Class attribute the NAS echoes into accounting records",
536 ),
537 p(
538 "class_encoding",
539 "string",
540 "'utf8' (default) or 'hex' — how to read class",
541 ),
542 p(
543 "vendor_attributes",
544 "array",
545 "Vendor-Specific attributes: [{vendor_id, vendor_type, value, value_encoding}]",
546 ),
547 ],
548 example: json!({
549 "type": "send_access_accept",
550 "reply_message": "Welcome to the network",
551 "framed_ip_address": "10.0.0.42",
552 "session_timeout": 3600,
553 "service_type": "Framed-User"
554 }),
555 log_template: Some(
556 crate::protocol::log_template::LogTemplate::new().with_info("-> RADIUS Access-Accept"),
557 ),
558 }
559}
560
561fn send_access_reject_action() -> ActionDefinition {
562 ActionDefinition {
563 name: "send_access_reject".to_string(),
564 description: "DENY access. Sends Access-Reject. This is the correct answer whenever \
565 the credentials are wrong, the user is unknown, or policy forbids the \
566 request — and it is a decision the server records as yours, distinct \
567 from the Access-Reject it sends when you answer nothing at all."
568 .to_string(),
569 parameters: vec![p(
570 "reply_message",
571 "string|array",
572 "Reason shown to the user (Reply-Message)",
573 )],
574 example: json!({
575 "type": "send_access_reject",
576 "reply_message": "Invalid credentials"
577 }),
578 log_template: Some(
579 crate::protocol::log_template::LogTemplate::new().with_info("-> RADIUS Access-Reject"),
580 ),
581 }
582}
583
584fn send_access_challenge_action() -> ActionDefinition {
585 ActionDefinition {
586 name: "send_access_challenge".to_string(),
587 description: "Ask for more information before deciding. Sends Access-Challenge with \
588 a State attribute the NAS will echo in its next Access-Request, so you \
589 can recognise the continuation. This is NOT a grant."
590 .to_string(),
591 parameters: vec![
592 required(
593 "state",
594 "string",
595 "Opaque State value the NAS must echo back",
596 ),
597 p(
598 "state_encoding",
599 "string",
600 "'utf8' (default) or 'hex' — how to read state",
601 ),
602 p("reply_message", "string|array", "Prompt shown to the user"),
603 p(
604 "session_timeout",
605 "number",
606 "How long the user has to answer, in seconds",
607 ),
608 ],
609 example: json!({
610 "type": "send_access_challenge",
611 "state": "otp-round-1",
612 "reply_message": "Enter your one-time code"
613 }),
614 log_template: Some(
615 crate::protocol::log_template::LogTemplate::new()
616 .with_info("-> RADIUS Access-Challenge"),
617 ),
618 }
619}
620
621fn send_accounting_response_action() -> ActionDefinition {
622 ActionDefinition {
623 name: "send_accounting_response".to_string(),
624 description: "Acknowledge an Accounting-Request. Answering nothing leaves the NAS to \
625 retransmit, which is the safe default if you do not want to record the \
626 session."
627 .to_string(),
628 parameters: vec![],
629 example: json!({ "type": "send_accounting_response" }),
630 log_template: Some(
631 crate::protocol::log_template::LogTemplate::new()
632 .with_info("-> RADIUS Accounting-Response"),
633 ),
634 }
635}
636
637impl Protocol for RadiusProtocol {
642 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
643 Vec::new()
644 }
645
646 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
647 vec![
648 send_access_accept_action(),
649 send_access_reject_action(),
650 send_access_challenge_action(),
651 send_accounting_response_action(),
652 ]
653 }
654
655 fn protocol_name(&self) -> &'static str {
656 "RADIUS"
657 }
658
659 fn get_event_types(&self) -> Vec<EventType> {
660 vec![
661 RADIUS_ACCESS_REQUEST_EVENT.clone(),
662 RADIUS_ACCOUNTING_REQUEST_EVENT.clone(),
663 RADIUS_STATUS_SERVER_EVENT.clone(),
664 ]
665 }
666
667 fn stack_name(&self) -> &'static str {
668 "ETH>IP>UDP>RADIUS"
669 }
670
671 fn keywords(&self) -> Vec<&'static str> {
672 vec!["radius", "radius server", "aaa server", "rfc 2865"]
675 }
676
677 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
678 use crate::protocol::metadata::{
679 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
680 };
681
682 ProtocolMetadataV2::builder()
683 .connectionless()
684 .answers_on_failure()
688 .state(DevelopmentState::Beta)
695 .privilege_requirement(PrivilegeRequirement::None)
697 .well_known_udp_port(1812)
698 .implementation(
699 "Hand-rolled RFC 2865/2866 codec (src/server/radius/packet.rs) over a tokio \
700 UdpSocket. Implements the Response Authenticator MD5, the Accounting-Request \
701 Authenticator (verified, not just computed), User-Password unhiding per \
702 §5.2, TLV attributes, Proxy-State echo and the RFC 3579 §3.2 \
703 Message-Authenticator (first in every Access reply; verified on a request \
704 that carries one). Does NOT implement CHAP, MS-CHAP or EAP: \
705 CHAP-Password and EAP-Message are handed to the model as opaque hex and no \
706 challenge is validated.",
707 )
708 .llm_control(
709 "The model makes the authorization decision — Access-Accept, Access-Reject \
710 or Access-Challenge — and chooses the reply attributes (Framed-IP-Address, \
711 Session-Timeout, Filter-Id, Class, vendor-specific).",
712 )
713 .e2e_testing(
714 "Validated against FreeRADIUS radclient, an independent implementation that \
715 NetGet did not write and does not link. It verifies our Response \
716 Authenticator itself and refuses the reply with 'invalid Response \
717 Authenticator' if the MD5 is wrong, so it checks the one thing that \
718 matters. Two tests in tests/server/radius/real_client_test.rs, neither \
719 #[ignore]d: radclient accepts a model-authorised Access-Accept and decodes \
720 the model's Reply-Message, Framed-IP-Address and Session-Timeout from its \
721 own dictionary; and radclient reads the FAIL-CLOSED Access-Reject produced \
722 when the model answers nothing, which must also be correctly signed (a \
723 denial the client discards as corrupt is a timeout, not a denial). Both \
724 FAIL, not skip, when radclient is absent — a skip-when-missing gate is a \
725 silent pass rather than evidence, and that gate was the only thing keeping \
726 this protocol at Experimental. FreeRADIUS is therefore required wherever \
727 the `radius` feature's suite runs (`brew install freeradius-server` / \
728 `apt-get install -y freeradius-utils`); it is not in CI's CI_FEATURES, so \
729 the CI gate neither compiles nor runs these tests. The codec is \
730 additionally pinned to RFC 2865 §7.1/§7.2 literal example bytes, checked \
731 with a separate MD5 implementation. CHAP, MS-CHAP and EAP are \
732 unimplemented and untested.",
733 )
734 .notes(
735 "FAILS CLOSED: no LLM answer, an unusable answer, or an LLM error all \
736 produce Access-Reject, logged as decision=fail_closed_* and never as \
737 decision=model_reject. shared_secret is a required startup parameter; the \
738 server refuses to start without one. Accounting-Request packets whose \
739 Authenticator does not verify are dropped.",
740 )
741 .max_inbound_bytes(crate::server::radius::packet::MAX_PACKET_LEN)
742 .build()
743 }
744
745 fn description(&self) -> &'static str {
746 "RADIUS authentication, authorization and accounting server (RFC 2865/2866)"
747 }
748
749 fn example_prompt(&self) -> &'static str {
750 "run a radius server on port 1812 with shared secret testing123; accept alice with \
751 password wonderland and give her 10.0.0.42, reject everyone else"
752 }
753
754 fn group_name(&self) -> &'static str {
755 "Application"
756 }
757
758 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
759 vec![ParameterDefinition {
760 name: "shared_secret".to_string(),
761 type_hint: "string".to_string(),
762 description: "Shared secret between this server and the NAS. Required: it keys \
763 the Response Authenticator and the User-Password decryption, and \
764 the server refuses to start without it rather than using a \
765 guessable default."
766 .to_string(),
767 required: true,
768 example: json!("testing123"),
769 default: None,
770 }]
771 }
772
773 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
774 use crate::llm::actions::StartupExamples;
775
776 StartupExamples::new(
777 json!({
779 "type": "open_server",
780 "port": 1812,
781 "base_stack": "radius",
782 "startup_params": { "shared_secret": "testing123" },
783 "instruction": "Accept user alice with password wonderland, assigning \
784 10.0.0.42 and a 1 hour session. Reject every other user."
785 }),
786 json!({
788 "type": "open_server",
789 "port": 1812,
790 "base_stack": "radius",
791 "startup_params": { "shared_secret": "testing123" },
792 "event_handlers": [{
793 "event_pattern": "radius_access_request",
794 "handler": {
795 "type": "script",
796 "language": "python",
797 "code": "respond([{'type': 'send_access_accept'}] if event['user_name'] == 'alice' and event.get('password') == 'wonderland' else [{'type': 'send_access_reject', 'reply_message': 'denied'}])"
798 }
799 }]
800 }),
801 json!({
803 "type": "open_server",
804 "port": 1812,
805 "base_stack": "radius",
806 "startup_params": { "shared_secret": "testing123" },
807 "event_handlers": [{
808 "event_pattern": "radius_access_request",
809 "handler": {
810 "type": "static",
811 "actions": [{
812 "type": "send_access_reject",
813 "reply_message": "This server denies everyone"
814 }]
815 }
816 }]
817 }),
818 )
819 }
820}
821
822impl Server for RadiusProtocol {
823 fn spawn(
824 &self,
825 ctx: crate::protocol::SpawnContext,
826 ) -> std::pin::Pin<
827 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
828 > {
829 Box::pin(async move { super::RadiusServer::spawn_with_llm_actions(ctx).await })
830 }
831
832 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
833 let action_type = action
834 .get("type")
835 .and_then(|v| v.as_str())
836 .context("Missing 'type' field in action")?;
837
838 match action_type {
839 "send_access_accept" => {
840 let mut attributes = Vec::new();
841 push_optional_reply_message(&mut attributes, &action);
842
843 if let Some(ip) = parse_ipv4(&action, "framed_ip_address")? {
844 attributes.push(Attribute::ipv4(packet::ATTR_FRAMED_IP_ADDRESS, ip));
845 }
846 if let Some(mask) = parse_ipv4(&action, "framed_ip_netmask")? {
847 attributes.push(Attribute::ipv4(packet::ATTR_FRAMED_IP_NETMASK, mask));
848 }
849 if let Some(v) = action.get("framed_protocol") {
850 attributes.push(Attribute::integer(
851 packet::ATTR_FRAMED_PROTOCOL,
852 framed_protocol_value(v)?,
853 ));
854 }
855 if let Some(mtu) = parse_u32(&action, "framed_mtu")? {
856 attributes.push(Attribute::integer(packet::ATTR_FRAMED_MTU, mtu));
857 }
858 if let Some(v) = action.get("service_type") {
859 attributes.push(Attribute::integer(
860 packet::ATTR_SERVICE_TYPE,
861 service_type_value(v)?,
862 ));
863 }
864 if let Some(t) = parse_u32(&action, "session_timeout")? {
865 attributes.push(Attribute::integer(packet::ATTR_SESSION_TIMEOUT, t));
866 }
867 if let Some(t) = parse_u32(&action, "idle_timeout")? {
868 attributes.push(Attribute::integer(packet::ATTR_IDLE_TIMEOUT, t));
869 }
870 if let Some(f) = action.get("filter_id").and_then(|v| v.as_str()) {
871 attributes.push(Attribute::text(packet::ATTR_FILTER_ID, f));
872 }
873 if let Some(class) = decode_encoded_field(&action, "class", "class_encoding")? {
874 attributes.push(Attribute::new(packet::ATTR_CLASS, class));
875 }
876 push_vendor_attributes(&mut attributes, &action)?;
877
878 self.reply(packet::CODE_ACCESS_ACCEPT, attributes)
879 }
880 "send_access_reject" => {
881 let mut attributes = Vec::new();
882 push_optional_reply_message(&mut attributes, &action);
883 self.reply(packet::CODE_ACCESS_REJECT, attributes)
884 }
885 "send_access_challenge" => {
886 let state = decode_encoded_field(&action, "state", "state_encoding")?.context(
887 "send_access_challenge requires 'state': without it the NAS cannot tie \
888 its next Access-Request to this challenge",
889 )?;
890 if state.is_empty() {
891 return Err(anyhow::anyhow!(
892 "send_access_challenge 'state' must not be empty"
893 ));
894 }
895 let mut attributes = vec![Attribute::new(packet::ATTR_STATE, state)];
896 push_optional_reply_message(&mut attributes, &action);
897 if let Some(t) = parse_u32(&action, "session_timeout")? {
898 attributes.push(Attribute::integer(packet::ATTR_SESSION_TIMEOUT, t));
899 }
900 self.reply(packet::CODE_ACCESS_CHALLENGE, attributes)
901 }
902 "send_accounting_response" => self.reply(packet::CODE_ACCOUNTING_RESPONSE, Vec::new()),
903 _ => Err(anyhow::anyhow!("Unknown RADIUS action: {}", action_type)),
904 }
905 }
906}
907
908pub fn attributes_json(request: &RadiusPacket) -> serde_json::Value {
915 let items: Vec<_> = request
916 .attributes
917 .iter()
918 .map(|attr| {
919 let (name, _) = packet::attribute_info(attr.attr_type);
920 json!({
921 "type": attr.attr_type,
922 "name": if name == "Unknown" {
923 format!("Attribute-{}", attr.attr_type)
924 } else {
925 name.to_string()
926 },
927 "value": packet::attribute_value_json(attr.attr_type, &attr.value),
928 })
929 })
930 .collect();
931 serde_json::Value::Array(items)
932}
933
934pub fn text_attr(request: &RadiusPacket, attr_type: u8) -> Option<String> {
936 request
937 .first(attr_type)
938 .map(|v| String::from_utf8_lossy(v).into_owned())
939}
940
941pub fn int_attr(request: &RadiusPacket, attr_type: u8) -> Option<u32> {
943 request.first(attr_type).and_then(|v| {
944 if v.len() == 4 {
945 Some(u32::from_be_bytes([v[0], v[1], v[2], v[3]]))
946 } else {
947 None
948 }
949 })
950}
951
952pub fn ip_attr(request: &RadiusPacket, attr_type: u8) -> Option<String> {
954 request.first(attr_type).and_then(|v| {
955 if v.len() == 4 {
956 Some(Ipv4Addr::new(v[0], v[1], v[2], v[3]).to_string())
957 } else {
958 None
959 }
960 })
961}