1use crate::llm::actions::{
38 protocol_trait::{ActionResult, Protocol, Server},
39 ActionDefinition, Parameter, ParameterDefinition,
40};
41use crate::protocol::EventType;
42use crate::state::app_state::AppState;
43use anyhow::{Context, Result};
44use serde_json::json;
45use std::sync::LazyLock;
46
47use super::codec;
48
49#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
52pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
53#[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd")))]
54pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
55
56pub const AUTHENTICATOR_NAME: &str = "netget";
58
59pub const TRANSPORT_RAW: &str = "raw";
61pub const TRANSPORT_UDP: &str = "udp";
62
63pub struct EapolProtocol {
74 request: Option<RequestContext>,
75}
76
77#[derive(Clone, Debug)]
79pub struct RequestContext {
80 pub eapol_version: u8,
82 pub request_identifier: u8,
85 pub result_identifier: u8,
89 pub identity_established: bool,
91 pub md5_challenge: Vec<u8>,
93}
94
95impl Default for EapolProtocol {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl EapolProtocol {
102 pub fn new() -> Self {
103 Self { request: None }
104 }
105
106 pub fn for_request(ctx: RequestContext) -> Self {
107 Self { request: Some(ctx) }
108 }
109
110 fn ctx(&self) -> Result<&RequestContext> {
114 self.request.as_ref().context(
115 "EAPOL action executed without a request context: an EAP frame must echo the \
116 identifier of the packet it answers, and this instance came from the registry \
117 rather than from a received frame.",
118 )
119 }
120}
121
122fn method_type_value(v: &serde_json::Value) -> Result<u8> {
132 if let Some(n) = v.as_u64() {
133 let n = u8::try_from(n).context("eap_type must fit in one octet")?;
134 return match n {
135 codec::EAP_TYPE_MD5_CHALLENGE | codec::EAP_TYPE_TLS | codec::EAP_TYPE_PEAP => Ok(n),
136 other => Err(anyhow::anyhow!(
137 "EAP method type {} is not implemented by this authenticator. Implemented: \
138 4 (md5-challenge), 13 (tls), 25 (peap).",
139 other
140 )),
141 };
142 }
143 let name = v
144 .as_str()
145 .context("eap_type must be a method name or a number")?;
146 match name
147 .to_ascii_lowercase()
148 .replace(['-', '_', ' '], "")
149 .as_str()
150 {
151 "md5" | "md5challenge" | "eapmd5" => Ok(codec::EAP_TYPE_MD5_CHALLENGE),
152 "tls" | "eaptls" => Ok(codec::EAP_TYPE_TLS),
153 "peap" => Ok(codec::EAP_TYPE_PEAP),
154 other => Err(anyhow::anyhow!(
155 "Unknown or unimplemented eap_type '{}'. This authenticator implements \
156 'md5-challenge' (challenge generated and verified by NetGet), 'tls' and 'peap' \
157 (the Start flag only — NetGet carries no TLS handshake). 'mschapv2' is \
158 deliberately absent: NetGet cannot verify an MSCHAPv2 response, and offering a \
159 method it cannot check would mean admitting on unverified evidence.",
160 other
161 )),
162 }
163}
164
165fn required_text(action: &serde_json::Value, key: &str) -> Result<String> {
166 action
167 .get(key)
168 .and_then(|v| v.as_str())
169 .map(|s| s.to_string())
170 .with_context(|| format!("'{}' is required and must be a string", key))
171}
172
173fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
178 Parameter {
179 name: name.to_string(),
180 type_hint: type_hint.to_string(),
181 description: description.to_string(),
182 required: false,
183 }
184}
185
186fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
187 Parameter {
188 name: name.to_string(),
189 type_hint: type_hint.to_string(),
190 description: description.to_string(),
191 required: true,
192 }
193}
194
195pub static EAPOL_START_EVENT: LazyLock<EventType> = LazyLock::new(|| {
196 EventType::new(
197 "eapol_start",
198 "A supplicant asked to begin 802.1X authentication on this port. Normally you answer \
199 with send_eap_request_identity to ask who it is. You cannot admit anything yet — \
200 nothing has claimed an identity — and if you return nothing the port is denied with \
201 EAP-Failure.",
202 json!({ "type": "send_eap_request_identity" }),
203 )
204 .with_parameters(vec![
205 p(
206 "source_mac",
207 "string",
208 "MAC address of the supplicant, e.g. '02:00:00:00:00:01'",
209 ),
210 p(
211 "destination_mac",
212 "string",
213 "MAC the frame was addressed to; normally the PAE group address 01:80:c2:00:00:03",
214 ),
215 p(
216 "eapol_version",
217 "number",
218 "EAPOL protocol version the supplicant used: 1 (802.1X-2001), 2 (802.1X-2004), \
219 3 (802.1X-2010)",
220 ),
221 ])
222 .with_actions(vec![
223 send_eap_request_identity_action(),
227 send_eap_request_method_action(),
228 send_eap_notification_action(),
229 send_eap_failure_action(),
230 ])
231});
232
233pub static EAPOL_IDENTITY_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
234 EventType::new(
235 "eapol_identity_response",
236 "A supplicant claimed an identity. THIS IS AN ADMISSION DECISION. Answer \
237 send_eap_success only if this identity should be given access to the network; \
238 send_eap_failure to deny it; or send_eap_request_method to challenge it for proof \
239 first. An identity is a claim, not evidence — returning nothing denies the port.",
240 json!({ "type": "send_eap_request_method", "eap_type": "md5-challenge",
241 "expected_password": "hunter2" }),
242 )
243 .with_parameters(vec![
244 p(
245 "identity",
246 "string",
247 "The identity the supplicant claims, e.g. 'alice' or 'host/laptop.example.com'. \
248 Unverified.",
249 ),
250 p("source_mac", "string", "MAC address of the supplicant"),
251 p(
252 "eap_identifier",
253 "number",
254 "EAP identifier of this Response. Echoed automatically onto whatever you send; it \
255 is not a parameter of any action.",
256 ),
257 p("eapol_version", "number", "EAPOL protocol version in use"),
258 ])
259 .with_actions(vec![
260 send_eap_success_action(),
261 send_eap_failure_action(),
262 send_eap_request_method_action(),
263 send_eap_notification_action(),
264 ])
265});
266
267pub static EAPOL_METHOD_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
268 EventType::new(
269 "eapol_method_response",
270 "A supplicant answered an EAP method challenge. THIS IS AN ADMISSION DECISION. For \
271 MD5-Challenge, read md5_verification: NetGet has already compared the digest, and \
272 'verified' is the only value that means the supplicant proved the password. Anything \
273 else means it did not. Returning nothing denies the port.",
274 json!({ "type": "send_eap_success" }),
275 )
276 .with_parameters(vec![
277 p(
278 "eap_type",
279 "string",
280 "Method the supplicant answered with: 'md5-challenge', 'nak', 'tls', 'peap', \
281 'notification' or 'unknown'",
282 ),
283 p(
284 "eap_type_number",
285 "number",
286 "The same method as its IANA number",
287 ),
288 p(
289 "identity",
290 "string",
291 "Identity this session claimed earlier, or null",
292 ),
293 p("source_mac", "string", "MAC address of the supplicant"),
294 p(
295 "eap_identifier",
296 "number",
297 "EAP identifier of this Response, echoed automatically onto your reply",
298 ),
299 p("eapol_version", "number", "EAPOL protocol version in use"),
300 p(
301 "md5_verification",
302 "string",
303 "MD5-Challenge only. 'verified' — the digest matches the password you supplied. \
304 'mismatch' — it does not; the supplicant does not hold that password. \
305 'not_checked_no_expected_password' — you never supplied one, so NOTHING was \
306 verified and this response is no evidence of anything. \
307 'malformed' — the response could not be parsed. \
308 'unsolicited' — no challenge was outstanding for this session.",
309 ),
310 p(
311 "md5_verified",
312 "boolean",
313 "True only when md5_verification is 'verified'. False in every other case, \
314 including when no password was supplied to compare against.",
315 ),
316 p(
317 "desired_eap_types",
318 "array",
319 "Legacy-NAK only (RFC 3748 §5.3): method names the supplicant would rather use",
320 ),
321 p(
322 "tls_start",
323 "boolean",
324 "EAP-TLS/PEAP only: whether the Start flag is set (RFC 5216 §3.1)",
325 ),
326 p(
327 "tls_more_fragments",
328 "boolean",
329 "EAP-TLS/PEAP only: whether more fragments follow",
330 ),
331 p(
332 "tls_length_included",
333 "boolean",
334 "EAP-TLS/PEAP only: whether a total-length field is present",
335 ),
336 p(
337 "type_data_length",
338 "number",
339 "Octets of method data the supplicant sent. NetGet does not hand you the bytes: \
340 models cannot read raw payloads reliably and must not be asked to.",
341 ),
342 ])
343 .with_actions(vec![
344 send_eap_success_action(),
345 send_eap_failure_action(),
346 send_eap_request_method_action(),
347 send_eap_notification_action(),
348 ])
349});
350
351pub static EAPOL_LOGOFF_EVENT: LazyLock<EventType> = LazyLock::new(|| {
352 EventType::new(
353 "eapol_logoff",
354 "A supplicant logged off. The session has ALREADY been destroyed and the port \
355 de-authorized before you were asked — that is not your decision to make. You may send \
356 a notification or an explicit EAP-Failure to confirm; answering nothing is fine and \
357 puts nothing on the wire.",
358 json!({ "type": "send_eap_failure" }),
359 )
360 .with_parameters(vec![
361 p("source_mac", "string", "MAC address of the supplicant"),
362 p(
363 "identity",
364 "string",
365 "Identity the session had claimed, or null if it never got that far",
366 ),
367 p("eapol_version", "number", "EAPOL protocol version in use"),
368 ])
369 .with_actions(vec![
370 send_eap_notification_action(),
371 send_eap_failure_action(),
372 ])
373});
374
375fn send_eap_request_identity_action() -> ActionDefinition {
380 ActionDefinition {
381 name: "send_eap_request_identity".to_string(),
382 description: "Ask the supplicant who it is. Sends EAP-Request/Identity. This grants \
383 nothing; it starts the conversation. The EAP identifier is allocated by \
384 the server and is not yours to choose."
385 .to_string(),
386 parameters: vec![],
387 example: json!({ "type": "send_eap_request_identity" }),
388 log_template: None,
389 }
390}
391
392fn send_eap_request_method_action() -> ActionDefinition {
393 ActionDefinition {
394 name: "send_eap_request_method".to_string(),
395 description: "Challenge the supplicant to prove its identity with an EAP method. This \
396 grants nothing. For 'md5-challenge', NetGet generates the challenge and \
397 verifies the digest itself; supply expected_password and you will be \
398 told in the next event whether it matched. Without expected_password \
399 nothing is verified and the answer proves nothing."
400 .to_string(),
401 parameters: vec![
402 required(
403 "eap_type",
404 "string|number",
405 "'md5-challenge' (4), 'tls' (13) or 'peap' (25). TLS and PEAP send only the \
406 Start flag: NetGet carries no TLS handshake.",
407 ),
408 p(
409 "expected_password",
410 "string",
411 "MD5-Challenge only: the password this identity should hold. NetGet computes \
412 MD5(identifier || password || challenge) per RFC 1994 §2.2 and compares it \
413 with the supplicant's answer, reporting the result as md5_verification.",
414 ),
415 ],
416 example: json!({
417 "type": "send_eap_request_method",
418 "eap_type": "md5-challenge",
419 "expected_password": "hunter2"
420 }),
421 log_template: None,
422 }
423}
424
425fn send_eap_notification_action() -> ActionDefinition {
426 ActionDefinition {
427 name: "send_eap_notification".to_string(),
428 description: "Send a displayable message to the supplicant (EAP-Request/Notification, \
429 RFC 3748 §5.2). It is not a decision and does not end the exchange."
430 .to_string(),
431 parameters: vec![required(
432 "text",
433 "string",
434 "Message to display, e.g. 'Guest access requires registration'",
435 )],
436 example: json!({
437 "type": "send_eap_notification",
438 "text": "Contact IT to register this device"
439 }),
440 log_template: None,
441 }
442}
443
444fn send_eap_success_action() -> ActionDefinition {
445 ActionDefinition {
446 name: "send_eap_success".to_string(),
447 description: "ADMIT THIS DEVICE. Sends EAP-Success, which opens the switch port and \
448 puts the supplicant on the network. Use it only when the identity is \
449 known AND has actually proved itself — for MD5-Challenge that means \
450 md5_verification was 'verified'. An identity on its own is a claim, not \
451 evidence. There is no way to send this frame by accident: it is refused \
452 unless the session has already produced an identity, and the server \
453 never synthesises it on any error, timeout or fallback path."
454 .to_string(),
455 parameters: vec![],
456 example: json!({ "type": "send_eap_success" }),
457 log_template: None,
458 }
459}
460
461fn send_eap_failure_action() -> ActionDefinition {
462 ActionDefinition {
463 name: "send_eap_failure".to_string(),
464 description: "DENY THIS DEVICE. Sends EAP-Failure and leaves the port unauthorized. \
465 This is the right answer whenever the identity is unknown, the proof \
466 failed, or policy forbids it — and the server records it as YOUR \
467 decision, distinct from the EAP-Failure it sends on its own when you \
468 answer nothing at all."
469 .to_string(),
470 parameters: vec![],
471 example: json!({ "type": "send_eap_failure" }),
472 log_template: None,
473 }
474}
475
476impl Protocol for EapolProtocol {
481 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
482 Vec::new()
483 }
484
485 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
486 vec![
487 send_eap_request_identity_action(),
488 send_eap_request_method_action(),
489 send_eap_notification_action(),
490 send_eap_success_action(),
491 send_eap_failure_action(),
492 ]
493 }
494
495 fn protocol_name(&self) -> &'static str {
496 "EAPOL"
497 }
498
499 fn get_event_types(&self) -> Vec<EventType> {
500 vec![
501 EAPOL_START_EVENT.clone(),
502 EAPOL_IDENTITY_RESPONSE_EVENT.clone(),
503 EAPOL_METHOD_RESPONSE_EVENT.clone(),
504 EAPOL_LOGOFF_EVENT.clone(),
505 ]
506 }
507
508 fn stack_name(&self) -> &'static str {
509 "ETH>EAPOL>EAP"
510 }
511
512 fn keywords(&self) -> Vec<&'static str> {
513 vec![
516 "eapol",
517 "802.1x",
518 "8021x",
519 "dot1x",
520 "port authentication",
521 "network access control",
522 ]
523 }
524
525 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
526 Some(crate::protocol::BindingDefaults {
530 mac_address: None,
531 interface: Some(DEFAULT_LOOPBACK_INTERFACE.to_string()),
532 host: Some("127.0.0.1".to_string()),
533 port: Some(0),
534 })
535 }
536
537 fn startup_privilege_requirement(
538 &self,
539
540 startup_params: Option<&serde_json::Value>,
541 ) -> crate::protocol::metadata::PrivilegeRequirement {
542 crate::llm::actions::protocol_trait::unless_udp_transport(
545 self.metadata().privilege_requirement,
546 startup_params,
547 )
548 }
549
550 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
551 use crate::protocol::metadata::{
552 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
553 };
554
555 ProtocolMetadataV2::builder()
556 .state(DevelopmentState::Experimental)
557 .answers_on_failure()
561 .privilege_requirement(PrivilegeRequirement::PacketCapture)
574 .implementation(
575 "Hand-rolled IEEE 802.1X-2004 §11.3 / RFC 3748 codec (src/server/eapol/codec.rs) \
576 implementing the authenticator (switch-port) role: EAPOL-Start, EAPOL-Logoff, \
577 EAP-Request/Identity, EAP-Request/Notification, EAP-Request/MD5-Challenge, \
578 EAP-Success and EAP-Failure, with the EAP identifier echoed per RFC 3748 §4.2. \
579 MD5-Challenge is fully performed by NetGet: the challenge is generated here and \
580 the RFC 1994 §2.2 digest is verified here (constant-time) using the md-5 crate, \
581 the same one src/server/radius uses, so the model is never asked for \
582 cryptographic material. NOT implemented: EAP-TLS, PEAP and MSCHAPv2 as \
583 methods (TLS/PEAP send only the RFC 5216 Start flag and no handshake is carried), \
584 EAPOL-Key / MKA, RADIUS pass-through, and VLAN assignment. Two transports: raw \
585 Ethernet on EtherType 0x888E via libpcap, and a UDP test transport carrying the \
586 same EAPOL frames prefixed by the supplicant's 6-octet MAC.",
587 )
588 .llm_control(
589 "The model makes the admission decision. It sees the claimed identity, the \
590 supplicant MAC and — for MD5-Challenge — NetGet's own verdict on the digest, \
591 and answers with EAP-Success (admit), EAP-Failure (deny), a further method \
592 challenge, or a notification.",
593 )
594 .e2e_testing(
595 "Codec pinned byte-for-byte against literal IEEE 802.1X / RFC 3748 frames and \
596 the RFC 1321 §A.5 MD5 digest suite, with no network and no LLM. The full \
597 event -> LLM -> action -> frame path is driven end to end over the UDP test \
598 transport with a mocked model, including the fail-closed regressions. NO \
599 third-party supplicant has ever spoken to this server, and the raw-Ethernet \
600 transport has never been executed — see the module CLAUDE.md for the exact \
601 path to changing that.",
602 )
603 .notes(
604 "EXPERIMENTAL, and precisely: the codec and the fail-closed logic are proven by \
605 tests; the raw-Ethernet transport is unproven and has never run, because it \
606 needs packet-capture privilege this environment does not have. FAILS CLOSED: \
607 no LLM answer, an unusable answer, an action that fails to encode, or an LLM \
608 error all produce EAP-Failure, logged decision=fail_closed_* and never \
609 decision=model_reject. EAP-Success is emitted by exactly one function, reached \
610 only from an explicit send_eap_success on a session that already produced an \
611 identity.",
612 )
613 .max_inbound_bytes(crate::server::eapol::MAX_FRAME_LEN)
614 .build()
615 }
616
617 fn description(&self) -> &'static str {
618 "EAPOL / 802.1X port-based network access control authenticator (IEEE 802.1X, RFC 3748)"
619 }
620
621 fn example_prompt(&self) -> &'static str {
622 "act as an 802.1X authenticator on en0 via eapol; challenge every supplicant with \
623 MD5 and admit alice with password hunter2, deny everyone else"
624 }
625
626 fn group_name(&self) -> &'static str {
627 "Network"
628 }
629
630 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
631 vec![
632 ParameterDefinition {
633 name: "transport".to_string(),
634 type_hint: "string".to_string(),
635 description:
636 "'raw' (default) speaks real EAPOL on EtherType 0x888E over the chosen \
637 interface and needs packet-capture privilege. 'udp' is a test transport: \
638 the same EAPOL frames over a UDP socket, each datagram prefixed with the \
639 supplicant's 6-octet MAC address, so the whole authenticator can be \
640 exercised unprivileged."
641 .to_string(),
642 required: false,
643 example: json!("raw"),
644 default: None,
645 },
646 ParameterDefinition {
647 name: "eapol_version".to_string(),
648 type_hint: "number".to_string(),
649 description:
650 "EAPOL protocol version to send: 1 (802.1X-2001), 2 (802.1X-2004, default) \
651 or 3 (802.1X-2010). Replies otherwise echo the version the supplicant used."
652 .to_string(),
653 required: false,
654 example: json!(2),
655 default: None,
656 },
657 ]
658 }
659
660 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
661 use crate::llm::actions::StartupExamples;
662
663 StartupExamples::new(
664 json!({
666 "type": "open_server",
667 "base_stack": "eapol",
668 "interface": "en0",
669 "startup_params": { "transport": "raw" },
670 "instruction": "Challenge every supplicant with MD5-Challenge. Admit alice \
671 only if her password hunter2 verifies. Deny everyone else."
672 }),
673 json!({
675 "type": "open_server",
676 "base_stack": "eapol",
677 "interface": "en0",
678 "event_handlers": [{
679 "event_pattern": "eapol_start",
680 "handler": {
681 "type": "static",
682 "actions": [{"type": "send_eap_request_identity"}]
683 }
684 }, {
685 "event_pattern": "eapol_identity_response",
686 "handler": {
687 "type": "script",
688 "language": "python",
689 "code": "respond([{'type': 'send_eap_request_method', 'eap_type': 'md5-challenge', 'expected_password': 'hunter2'}] if event['identity'] == 'alice' else [{'type': 'send_eap_failure'}])"
690 }
691 }, {
692 "event_pattern": "eapol_method_response",
693 "handler": {
694 "type": "script",
695 "language": "python",
696 "code": "respond([{'type': 'send_eap_success'}] if event.get('md5_verified') else [{'type': 'send_eap_failure'}])"
697 }
698 }]
699 }),
700 json!({
702 "type": "open_server",
703 "base_stack": "eapol",
704 "interface": "en0",
705 "event_handlers": [{
706 "event_pattern": "*",
707 "handler": {
708 "type": "static",
709 "actions": [{"type": "send_eap_failure"}]
710 }
711 }]
712 }),
713 )
714 }
715}
716
717impl Server for EapolProtocol {
718 fn spawn(
719 &self,
720 ctx: crate::protocol::SpawnContext,
721 ) -> std::pin::Pin<
722 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
723 > {
724 Box::pin(async move { super::EapolServer::spawn_with_llm_actions(ctx).await })
725 }
726
727 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
733 let action_type = action
734 .get("type")
735 .and_then(|v| v.as_str())
736 .context("Missing 'type' field in action")?;
737
738 if !matches!(
745 action_type,
746 "send_eap_request_identity"
747 | "send_eap_request_method"
748 | "send_eap_notification"
749 | "send_eap_success"
750 | "send_eap_failure"
751 ) {
752 return Err(anyhow::anyhow!("Unknown EAPOL action: {}", action_type));
753 }
754
755 let ctx = self.ctx()?;
758
759 match action_type {
760 "send_eap_request_identity" => Ok(ActionResult::Output(
761 codec::eapol_wrap_eap(
762 ctx.eapol_version,
763 &codec::eap_request_identity(ctx.request_identifier),
764 )
765 .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
766 )),
767
768 "send_eap_request_method" => {
769 let eap_type = method_type_value(
770 action
771 .get("eap_type")
772 .context("send_eap_request_method requires 'eap_type'")?,
773 )?;
774
775 let eap = match eap_type {
776 codec::EAP_TYPE_MD5_CHALLENGE => codec::eap_request_md5_challenge(
777 ctx.request_identifier,
778 &ctx.md5_challenge,
779 AUTHENTICATOR_NAME,
780 ),
781 other => codec::eap_request_tls_start(ctx.request_identifier, other),
782 }
783 .map_err(|e| anyhow::anyhow!("Failed to encode EAP-Request: {}", e))?;
784
785 Ok(ActionResult::Output(
786 codec::eapol_wrap_eap(ctx.eapol_version, &eap)
787 .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
788 ))
789 }
790
791 "send_eap_notification" => {
792 let text = required_text(&action, "text")?;
793 if text.is_empty() {
794 return Err(anyhow::anyhow!(
795 "send_eap_notification 'text' must not be empty: an empty notification \
796 tells the supplicant nothing and still costs a round trip"
797 ));
798 }
799 let eap = codec::eap_request_notification(ctx.request_identifier, &text)
800 .map_err(|e| anyhow::anyhow!("Failed to encode EAP-Notification: {}", e))?;
801 Ok(ActionResult::Output(
802 codec::eapol_wrap_eap(ctx.eapol_version, &eap)
803 .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
804 ))
805 }
806
807 "send_eap_success" => {
812 if !ctx.identity_established {
813 return Err(anyhow::anyhow!(
814 "Refusing to send EAP-Success: this session has not produced an \
815 EAP-Response/Identity, so there is no identity to admit. Send \
816 send_eap_request_identity first."
817 ));
818 }
819 Ok(ActionResult::Output(codec::eapol_eap_success_frame(
820 ctx.eapol_version,
821 ctx.result_identifier,
822 )))
823 }
824
825 "send_eap_failure" => Ok(ActionResult::Output(codec::eapol_eap_failure_frame(
826 ctx.eapol_version,
827 ctx.result_identifier,
828 ))),
829
830 other => Err(anyhow::anyhow!("Unknown EAPOL action: {}", other)),
834 }
835 }
836}