Skip to main content

netget/server/eapol/
actions.rs

1//! EAPOL / 802.1X protocol vocabulary and executor.
2//!
3//! # Fail closed
4//!
5//! An `EAP-Success` frame **is** the admission decision: it is the octet that opens a switch
6//! port. The OAuth2 post-mortem in the root `CLAUDE.md` — where "the LLM returned nothing"
7//! fell through to a hardcoded access token, and a model's explicit denial became
8//! indistinguishable from its silence — is the reason this file is shaped the way it is. It
9//! copies `src/server/radius/actions.rs` deliberately, because that is the worked example the
10//! repository points at.
11//!
12//! Four structural rules, each of which a reader can point at:
13//!
14//! 1. **There is no default response.** `execute_action` produces only the frame a named
15//!    action asked for. Nothing here synthesises a Success.
16//! 2. **Admission and denial are different actions built by different functions.**
17//!    `send_eap_success` reaches [`codec::eapol_eap_success_frame`] and `send_eap_failure`
18//!    reaches [`codec::eapol_eap_failure_frame`]; the two builders share no code and neither
19//!    takes a parameter that could select the other.
20//! 3. **A Success is impossible without an established identity.** The per-request
21//!    [`RequestContext`] carries `identity_established`, set only when the session already
22//!    holds an `EAP-Response/Identity`. A registry instance carries no context at all and
23//!    cannot encode any frame.
24//! 4. **Silence is denial, and is logged as its own thing.** When the model returns no usable
25//!    action, or the LLM call fails, `mod.rs` synthesises an EAP-Failure and records
26//!    `decision=fail_closed_no_action` / `decision=fail_closed_llm_error` — never
27//!    `decision=model_reject`. See `EapolServer::decide`.
28//!
29//! # No cryptographic material comes from the model
30//!
31//! For MD5-Challenge the challenge is generated by NetGet (`RequestContext::md5_challenge`)
32//! and the supplicant's digest is verified by NetGet (`codec::md5_response_matches`). The
33//! model supplies the *password it expects*, which is ordinary structured text an operator
34//! instruction naturally carries, and then decides **whether the identity is admitted**. It is
35//! never asked for a hash, a nonce or a key.
36
37use 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/// Loopback name, matching `arp`/`datalink`. EAPOL is not observable there — it is a default
50/// that keeps the binding system happy, and the raw transport says so when it refuses.
51#[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
56/// The Name field NetGet puts in an EAP-Request/MD5-Challenge (RFC 1994 §4.1). Informational.
57pub const AUTHENTICATOR_NAME: &str = "netget";
58
59/// Transport selector values for the `transport` startup parameter.
60pub const TRANSPORT_RAW: &str = "raw";
61pub const TRANSPORT_UDP: &str = "udp";
62
63// ---------------------------------------------------------------------------
64// Protocol instance
65// ---------------------------------------------------------------------------
66
67/// EAPOL authenticator.
68///
69/// The registry holds a context-free instance from [`EapolProtocol::new`]; it can describe the
70/// vocabulary but cannot encode a single frame, because every frame needs an EAP identifier
71/// that only a received packet can supply. `mod.rs` builds one instance **per received frame**
72/// with [`EapolProtocol::for_request`].
73pub struct EapolProtocol {
74    request: Option<RequestContext>,
75}
76
77/// Everything a reply needs, taken from the frame being answered rather than from the model.
78#[derive(Clone, Debug)]
79pub struct RequestContext {
80    /// EAPOL protocol version to echo (1..=3).
81    pub eapol_version: u8,
82    /// Identifier to stamp on a **new** EAP-Request. Allocated by the session, never by the
83    /// model, so two overlapping exchanges cannot collide.
84    pub request_identifier: u8,
85    /// Identifier to stamp on an EAP-Success or EAP-Failure. RFC 3748 §4.2 requires it to
86    /// match the Response being answered; a supplicant silently discards a mismatch, which
87    /// looks exactly like a hang.
88    pub result_identifier: u8,
89    /// True only once the session holds an `EAP-Response/Identity`. Gate three on Success.
90    pub identity_established: bool,
91    /// Challenge NetGet generated for this exchange. The model never sees or chooses it.
92    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    /// The word "context" in this message is load-bearing twice over: it tells a reader why a
111    /// registry instance cannot answer, and `tests/executable_examples_test.rs` keys its
112    /// "needs a live request" exemption on it, exactly as `radius` does.
113    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
122// ---------------------------------------------------------------------------
123// Value coercion
124// ---------------------------------------------------------------------------
125
126/// Method types NetGet can actually put on the wire.
127///
128/// Refusing an unimplemented method by name is the honest answer. Quietly falling back to
129/// MD5-Challenge — or worse, to a Success — would be the fail-open shape this protocol exists
130/// to avoid.
131fn 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
173// ---------------------------------------------------------------------------
174// Events
175// ---------------------------------------------------------------------------
176
177fn 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        // Deliberately no send_eap_success: a device that has not claimed an identity is not
224        // a device you can admit, and the narrowest vocabulary that can express the right
225        // answer is the safest one.
226        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
375// ---------------------------------------------------------------------------
376// Actions
377// ---------------------------------------------------------------------------
378
379fn 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
476// ---------------------------------------------------------------------------
477// Protocol / Server impls
478// ---------------------------------------------------------------------------
479
480impl 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        // Narrow on purpose. "auth" or "802.1" would hijack keyword resolution from radius
514        // and from every other authentication protocol in the registry.
515        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        // Both halves are needed because this protocol has two transports: `transport: "raw"`
527        // uses the interface, `transport: "udp"` uses host and port. `BindingDefaults`'
528        // constructors each fill only one half, so the struct is built directly.
529        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        // The UDP test transport carries the same frames over an ordinary socket.
543
544        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: an authentication exchange the model could not decide gets
558            // EAP-Failure, which leaves the port unauthorized. EAPOL-Logoff stays silent because
559            // 802.1X defines no reply to it and the port is already de-authorized.
560            .answers_on_failure()
561            // libpcap capture/injection, never a SOCK_RAW. RawSockets would refuse to start
562            // on a host with /dev/bpf* access but no root — the "don't claim more than you
563            // need" rule that ospf got wrong by declaring Root when it wanted CAP_NET_RAW.
564            // `arp`, `datalink`, `isis`, `lldp`, `stp` and `cdp` all declare this variant.
565            //
566            // The gate is per-protocol and `server_startup` evaluates it *before* reading
567            // startup parameters, so `open_server` is refused on an unprivileged host even
568            // when `transport: "udp"` is requested. That is why
569            // `tests/server/eapol/e2e_test.rs` builds a `SpawnContext` and calls
570            // `Server::spawn` directly, as `bluetooth_ble_beacon` and `lldp` do. Declaring
571            // `None` to dodge the gate is the tempting fix and the wrong one: the transport
572            // anyone actually deploys is raw Ethernet, and the metadata has to say so.
573            .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            // LLM mode
665            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            // Script mode
674            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            // Static mode
701            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    /// Encode exactly the frame a named action asked for.
728    ///
729    /// Read the module header before changing anything here. In particular: there is no
730    /// wildcard arm that produces a frame, `send_eap_success` is the only path to
731    /// [`codec::eapol_eap_success_frame`], and that path is gated on `identity_established`.
732    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        // Resolve the name before the context, so an action this protocol does not have is
739        // reported as *unknown* rather than as a missing request. Both are refusals, but only
740        // one of them tells a model it guessed a verb that does not exist — and
741        // `tests/event_action_declarations_test.rs` detects an unexecutable advertised name
742        // by exactly that wording. Taking the context first made the `_` arm unreachable on a
743        // registry instance and hid the difference.
744        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        // Every remaining arm needs the request context, so take it once: a registry instance
756        // fails here rather than half-building a frame with an invented identifier.
757        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            // ---------------------------------------------------------------
808            // The admission decision. The only reachable call to
809            // codec::eapol_eap_success_frame in the whole crate.
810            // ---------------------------------------------------------------
811            "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            // Unreachable: the guard above already rejected every other name. Kept as an
831            // error rather than a panic, because "the two lists drifted apart" must not be a
832            // way to crash a running authenticator.
833            other => Err(anyhow::anyhow!("Unknown EAPOL action: {}", other)),
834        }
835    }
836}