Skip to main content

netget/server/radius/
actions.rs

1//! RADIUS protocol actions.
2//!
3//! # Fail closed
4//!
5//! RADIUS grants network access. The OAuth2 post-mortem in the root `CLAUDE.md` is the
6//! reason this file is shaped the way it is: a credential-granting protocol that falls
7//! through to a permissive default turns an LLM outage into an authorization bypass, and
8//! makes a model's explicit denial indistinguishable from its silence.
9//!
10//! Three structural rules follow, and each is enforced somewhere a reader can point at:
11//!
12//! 1. **There is no default response.** `execute_action` can only ever produce the packet a
13//!    named action asked for. Nothing here synthesises an Access-Accept.
14//! 2. **Acceptance and denial are different actions.** `send_access_accept` and
15//!    `send_access_reject` share no code path, so "the model said no" cannot decay into
16//!    "the model said nothing" or vice versa.
17//! 3. **Silence is denial, and it is logged as its own thing.** When the model returns no
18//!    usable action — or the LLM call fails outright — `mod.rs` synthesises an Access-Reject
19//!    and records `decision=fail_closed_no_action` / `decision=fail_closed_llm_error`, never
20//!    `decision=model_reject`. See `RadiusServer::decide`.
21//!
22//! A fourth rule lives in `spawn`: without a `shared_secret` the server refuses to start,
23//! rather than running with a guessable default.
24
25use 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
38/// RADIUS protocol handler.
39///
40/// The registry holds a context-free instance built with [`RadiusProtocol::new`]. The server
41/// builds one instance *per request* with [`RadiusProtocol::for_request`], carrying that
42/// request's identifier, Request Authenticator and the server's shared secret — everything
43/// needed to sign a reply. A context-free instance cannot sign anything and says so.
44pub struct RadiusProtocol {
45    request: Option<RequestContext>,
46}
47
48/// Per-request signing context.
49#[derive(Clone)]
50pub struct RequestContext {
51    /// Identifier of the request being answered; the reply must echo it.
52    pub identifier: u8,
53    /// The request's 16-byte Authenticator, an input to the Response Authenticator.
54    pub authenticator: [u8; 16],
55    /// Shared secret between this server and the NAS.
56    pub secret: Vec<u8>,
57    /// Proxy-State attributes to copy into the reply, in order (RFC 2865 §5.33).
58    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    /// Proxy-State attributes carried by the request being answered (RFC 2865 §5.33).
85    pub fn proxy_state(&self) -> &[Vec<u8>] {
86        match &self.request {
87            Some(ctx) => &ctx.proxy_state,
88            None => &[],
89        }
90    }
91
92    /// Encode and sign a reply of `code` with exactly the attributes given.
93    ///
94    /// Public so the server can build its own fail-closed Access-Reject without going
95    /// through an LLM action.
96    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    /// Build a signed reply of `code`, appending the request's Proxy-State attributes.
117    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
125// ---------------------------------------------------------------------------
126// Value coercion helpers — every documented field is really decoded here.
127// ---------------------------------------------------------------------------
128
129/// Well-known Service-Type values (RFC 2865 §5.6). Accepting the name as well as the number
130/// is not cosmetic: models produce `"Framed-User"` far more reliably than `2`.
131fn 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
167/// Well-known Framed-Protocol values (RFC 2865 §5.7).
168fn 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
193/// Decode a field documented as `utf8` or `hex`.
194///
195/// The `send_tcp_data` bug (`d70bb5b5`) is the reference case for why this exists as an
196/// explicit field rather than a sniff: `"48656c6c6f"` is simultaneously valid text and valid
197/// hex, and only the sender knows which it meant.
198fn 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
224/// Reply-Message is limited to 253 octets per attribute; longer text is split across
225/// several attributes, which is exactly what RFC 2865 §5.18 prescribes.
226fn 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
273/// Encode a Vendor-Specific attribute (type 26) in the RFC 2865 §5.26 recommended format:
274/// `Vendor-Id(4) | Vendor-Type(1) | Vendor-Length(1) | Value`.
275fn 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
316// ---------------------------------------------------------------------------
317// Events
318// ---------------------------------------------------------------------------
319
320pub 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
487// ---------------------------------------------------------------------------
488// Actions
489// ---------------------------------------------------------------------------
490
491fn 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
637// ---------------------------------------------------------------------------
638// Protocol / Server impls
639// ---------------------------------------------------------------------------
640
641impl 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        // Deliberately narrow. Generic words like "auth" or "aaa" would hijack keyword
673        // resolution for every other authentication protocol in the registry.
674        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: every undecided Access-Request gets a correctly signed
685            // Access-Reject (decision=fail_closed_*), which denies rather than asserts. Nothing here
686            // can synthesise an Access-Accept.
687            .answers_on_failure()
688            // Beta, September 2026. The bar this file's own CLAUDE.md sets is a real
689            // third-party client, not #[ignore]d, that FAILS rather than skips when the
690            // client is missing. tests/server/radius/real_client_test.rs is now all three:
691            // it drives FreeRADIUS `radclient`, and `require_radclient()` returns Err when
692            // the binary is absent. It was Experimental purely because that gate used to
693            // print SKIPPED and return Ok(()).
694            .state(DevelopmentState::Beta)
695            // 1812/1813 are above 1023, so PrivilegedPort would be dead code here.
696            .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            // LLM mode
778            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            // Script mode
787            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            // Static mode
802            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
908// ---------------------------------------------------------------------------
909// Event payload construction
910// ---------------------------------------------------------------------------
911
912/// Every attribute, by number and name, with a typed value. Attributes the dictionary does
913/// not name are still reported — a model that sees `Attribute-26` can at least say so.
914pub 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
934/// Read a named text attribute, if present.
935pub 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
941/// Read a named 32-bit integer attribute, if present and well formed.
942pub 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
952/// Read a named IPv4 attribute, if present and well formed.
953pub 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}