netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
//! EAPOL / 802.1X protocol vocabulary and executor.
//!
//! # Fail closed
//!
//! An `EAP-Success` frame **is** the admission decision: it is the octet that opens a switch
//! port. The OAuth2 post-mortem in the root `CLAUDE.md` — where "the LLM returned nothing"
//! fell through to a hardcoded access token, and a model's explicit denial became
//! indistinguishable from its silence — is the reason this file is shaped the way it is. It
//! copies `src/server/radius/actions.rs` deliberately, because that is the worked example the
//! repository points at.
//!
//! Four structural rules, each of which a reader can point at:
//!
//! 1. **There is no default response.** `execute_action` produces only the frame a named
//!    action asked for. Nothing here synthesises a Success.
//! 2. **Admission and denial are different actions built by different functions.**
//!    `send_eap_success` reaches [`codec::eapol_eap_success_frame`] and `send_eap_failure`
//!    reaches [`codec::eapol_eap_failure_frame`]; the two builders share no code and neither
//!    takes a parameter that could select the other.
//! 3. **A Success is impossible without an established identity.** The per-request
//!    [`RequestContext`] carries `identity_established`, set only when the session already
//!    holds an `EAP-Response/Identity`. A registry instance carries no context at all and
//!    cannot encode any frame.
//! 4. **Silence is denial, and is logged as its own thing.** When the model returns no usable
//!    action, or the LLM call fails, `mod.rs` synthesises an EAP-Failure and records
//!    `decision=fail_closed_no_action` / `decision=fail_closed_llm_error` — never
//!    `decision=model_reject`. See `EapolServer::decide`.
//!
//! # No cryptographic material comes from the model
//!
//! For MD5-Challenge the challenge is generated by NetGet (`RequestContext::md5_challenge`)
//! and the supplicant's digest is verified by NetGet (`codec::md5_response_matches`). The
//! model supplies the *password it expects*, which is ordinary structured text an operator
//! instruction naturally carries, and then decides **whether the identity is admitted**. It is
//! never asked for a hash, a nonce or a key.

use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter, ParameterDefinition,
};
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{Context, Result};
use serde_json::json;
use std::sync::LazyLock;

use super::codec;

/// Loopback name, matching `arp`/`datalink`. EAPOL is not observable there — it is a default
/// that keeps the binding system happy, and the raw transport says so when it refuses.
#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
#[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd")))]
pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";

/// The Name field NetGet puts in an EAP-Request/MD5-Challenge (RFC 1994 §4.1). Informational.
pub const AUTHENTICATOR_NAME: &str = "netget";

/// Transport selector values for the `transport` startup parameter.
pub const TRANSPORT_RAW: &str = "raw";
pub const TRANSPORT_UDP: &str = "udp";

// ---------------------------------------------------------------------------
// Protocol instance
// ---------------------------------------------------------------------------

/// EAPOL authenticator.
///
/// The registry holds a context-free instance from [`EapolProtocol::new`]; it can describe the
/// vocabulary but cannot encode a single frame, because every frame needs an EAP identifier
/// that only a received packet can supply. `mod.rs` builds one instance **per received frame**
/// with [`EapolProtocol::for_request`].
pub struct EapolProtocol {
    request: Option<RequestContext>,
}

/// Everything a reply needs, taken from the frame being answered rather than from the model.
#[derive(Clone, Debug)]
pub struct RequestContext {
    /// EAPOL protocol version to echo (1..=3).
    pub eapol_version: u8,
    /// Identifier to stamp on a **new** EAP-Request. Allocated by the session, never by the
    /// model, so two overlapping exchanges cannot collide.
    pub request_identifier: u8,
    /// Identifier to stamp on an EAP-Success or EAP-Failure. RFC 3748 §4.2 requires it to
    /// match the Response being answered; a supplicant silently discards a mismatch, which
    /// looks exactly like a hang.
    pub result_identifier: u8,
    /// True only once the session holds an `EAP-Response/Identity`. Gate three on Success.
    pub identity_established: bool,
    /// Challenge NetGet generated for this exchange. The model never sees or chooses it.
    pub md5_challenge: Vec<u8>,
}

impl Default for EapolProtocol {
    fn default() -> Self {
        Self::new()
    }
}

impl EapolProtocol {
    pub fn new() -> Self {
        Self { request: None }
    }

    pub fn for_request(ctx: RequestContext) -> Self {
        Self { request: Some(ctx) }
    }

    /// The word "context" in this message is load-bearing twice over: it tells a reader why a
    /// registry instance cannot answer, and `tests/executable_examples_test.rs` keys its
    /// "needs a live request" exemption on it, exactly as `radius` does.
    fn ctx(&self) -> Result<&RequestContext> {
        self.request.as_ref().context(
            "EAPOL action executed without a request context: an EAP frame must echo the \
             identifier of the packet it answers, and this instance came from the registry \
             rather than from a received frame.",
        )
    }
}

// ---------------------------------------------------------------------------
// Value coercion
// ---------------------------------------------------------------------------

/// Method types NetGet can actually put on the wire.
///
/// Refusing an unimplemented method by name is the honest answer. Quietly falling back to
/// MD5-Challenge — or worse, to a Success — would be the fail-open shape this protocol exists
/// to avoid.
fn method_type_value(v: &serde_json::Value) -> Result<u8> {
    if let Some(n) = v.as_u64() {
        let n = u8::try_from(n).context("eap_type must fit in one octet")?;
        return match n {
            codec::EAP_TYPE_MD5_CHALLENGE | codec::EAP_TYPE_TLS | codec::EAP_TYPE_PEAP => Ok(n),
            other => Err(anyhow::anyhow!(
                "EAP method type {} is not implemented by this authenticator. Implemented: \
                 4 (md5-challenge), 13 (tls), 25 (peap).",
                other
            )),
        };
    }
    let name = v
        .as_str()
        .context("eap_type must be a method name or a number")?;
    match name
        .to_ascii_lowercase()
        .replace(['-', '_', ' '], "")
        .as_str()
    {
        "md5" | "md5challenge" | "eapmd5" => Ok(codec::EAP_TYPE_MD5_CHALLENGE),
        "tls" | "eaptls" => Ok(codec::EAP_TYPE_TLS),
        "peap" => Ok(codec::EAP_TYPE_PEAP),
        other => Err(anyhow::anyhow!(
            "Unknown or unimplemented eap_type '{}'. This authenticator implements \
             'md5-challenge' (challenge generated and verified by NetGet), 'tls' and 'peap' \
             (the Start flag only — NetGet carries no TLS handshake). 'mschapv2' is \
             deliberately absent: NetGet cannot verify an MSCHAPv2 response, and offering a \
             method it cannot check would mean admitting on unverified evidence.",
            other
        )),
    }
}

fn required_text(action: &serde_json::Value, key: &str) -> Result<String> {
    action
        .get(key)
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .with_context(|| format!("'{}' is required and must be a string", key))
}

// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------

fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
    Parameter {
        name: name.to_string(),
        type_hint: type_hint.to_string(),
        description: description.to_string(),
        required: false,
    }
}

fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
    Parameter {
        name: name.to_string(),
        type_hint: type_hint.to_string(),
        description: description.to_string(),
        required: true,
    }
}

pub static EAPOL_START_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "eapol_start",
        "A supplicant asked to begin 802.1X authentication on this port. Normally you answer \
         with send_eap_request_identity to ask who it is. You cannot admit anything yet — \
         nothing has claimed an identity — and if you return nothing the port is denied with \
         EAP-Failure.",
        json!({ "type": "send_eap_request_identity" }),
    )
    .with_parameters(vec![
        p(
            "source_mac",
            "string",
            "MAC address of the supplicant, e.g. '02:00:00:00:00:01'",
        ),
        p(
            "destination_mac",
            "string",
            "MAC the frame was addressed to; normally the PAE group address 01:80:c2:00:00:03",
        ),
        p(
            "eapol_version",
            "number",
            "EAPOL protocol version the supplicant used: 1 (802.1X-2001), 2 (802.1X-2004), \
             3 (802.1X-2010)",
        ),
    ])
    .with_actions(vec![
        // Deliberately no send_eap_success: a device that has not claimed an identity is not
        // a device you can admit, and the narrowest vocabulary that can express the right
        // answer is the safest one.
        send_eap_request_identity_action(),
        send_eap_request_method_action(),
        send_eap_notification_action(),
        send_eap_failure_action(),
    ])
});

pub static EAPOL_IDENTITY_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "eapol_identity_response",
        "A supplicant claimed an identity. THIS IS AN ADMISSION DECISION. Answer \
         send_eap_success only if this identity should be given access to the network; \
         send_eap_failure to deny it; or send_eap_request_method to challenge it for proof \
         first. An identity is a claim, not evidence — returning nothing denies the port.",
        json!({ "type": "send_eap_request_method", "eap_type": "md5-challenge",
                "expected_password": "hunter2" }),
    )
    .with_parameters(vec![
        p(
            "identity",
            "string",
            "The identity the supplicant claims, e.g. 'alice' or 'host/laptop.example.com'. \
             Unverified.",
        ),
        p("source_mac", "string", "MAC address of the supplicant"),
        p(
            "eap_identifier",
            "number",
            "EAP identifier of this Response. Echoed automatically onto whatever you send; it \
             is not a parameter of any action.",
        ),
        p("eapol_version", "number", "EAPOL protocol version in use"),
    ])
    .with_actions(vec![
        send_eap_success_action(),
        send_eap_failure_action(),
        send_eap_request_method_action(),
        send_eap_notification_action(),
    ])
});

pub static EAPOL_METHOD_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "eapol_method_response",
        "A supplicant answered an EAP method challenge. THIS IS AN ADMISSION DECISION. For \
         MD5-Challenge, read md5_verification: NetGet has already compared the digest, and \
         'verified' is the only value that means the supplicant proved the password. Anything \
         else means it did not. Returning nothing denies the port.",
        json!({ "type": "send_eap_success" }),
    )
    .with_parameters(vec![
        p(
            "eap_type",
            "string",
            "Method the supplicant answered with: 'md5-challenge', 'nak', 'tls', 'peap', \
             'notification' or 'unknown'",
        ),
        p(
            "eap_type_number",
            "number",
            "The same method as its IANA number",
        ),
        p(
            "identity",
            "string",
            "Identity this session claimed earlier, or null",
        ),
        p("source_mac", "string", "MAC address of the supplicant"),
        p(
            "eap_identifier",
            "number",
            "EAP identifier of this Response, echoed automatically onto your reply",
        ),
        p("eapol_version", "number", "EAPOL protocol version in use"),
        p(
            "md5_verification",
            "string",
            "MD5-Challenge only. 'verified' — the digest matches the password you supplied. \
             'mismatch' — it does not; the supplicant does not hold that password. \
             'not_checked_no_expected_password' — you never supplied one, so NOTHING was \
             verified and this response is no evidence of anything. \
             'malformed' — the response could not be parsed. \
             'unsolicited' — no challenge was outstanding for this session.",
        ),
        p(
            "md5_verified",
            "boolean",
            "True only when md5_verification is 'verified'. False in every other case, \
             including when no password was supplied to compare against.",
        ),
        p(
            "desired_eap_types",
            "array",
            "Legacy-NAK only (RFC 3748 §5.3): method names the supplicant would rather use",
        ),
        p(
            "tls_start",
            "boolean",
            "EAP-TLS/PEAP only: whether the Start flag is set (RFC 5216 §3.1)",
        ),
        p(
            "tls_more_fragments",
            "boolean",
            "EAP-TLS/PEAP only: whether more fragments follow",
        ),
        p(
            "tls_length_included",
            "boolean",
            "EAP-TLS/PEAP only: whether a total-length field is present",
        ),
        p(
            "type_data_length",
            "number",
            "Octets of method data the supplicant sent. NetGet does not hand you the bytes: \
             models cannot read raw payloads reliably and must not be asked to.",
        ),
    ])
    .with_actions(vec![
        send_eap_success_action(),
        send_eap_failure_action(),
        send_eap_request_method_action(),
        send_eap_notification_action(),
    ])
});

pub static EAPOL_LOGOFF_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "eapol_logoff",
        "A supplicant logged off. The session has ALREADY been destroyed and the port \
         de-authorized before you were asked — that is not your decision to make. You may send \
         a notification or an explicit EAP-Failure to confirm; answering nothing is fine and \
         puts nothing on the wire.",
        json!({ "type": "send_eap_failure" }),
    )
    .with_parameters(vec![
        p("source_mac", "string", "MAC address of the supplicant"),
        p(
            "identity",
            "string",
            "Identity the session had claimed, or null if it never got that far",
        ),
        p("eapol_version", "number", "EAPOL protocol version in use"),
    ])
    .with_actions(vec![
        send_eap_notification_action(),
        send_eap_failure_action(),
    ])
});

// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------

fn send_eap_request_identity_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_eap_request_identity".to_string(),
        description: "Ask the supplicant who it is. Sends EAP-Request/Identity. This grants \
                      nothing; it starts the conversation. The EAP identifier is allocated by \
                      the server and is not yours to choose."
            .to_string(),
        parameters: vec![],
        example: json!({ "type": "send_eap_request_identity" }),
        log_template: None,
    }
}

fn send_eap_request_method_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_eap_request_method".to_string(),
        description: "Challenge the supplicant to prove its identity with an EAP method. This \
                      grants nothing. For 'md5-challenge', NetGet generates the challenge and \
                      verifies the digest itself; supply expected_password and you will be \
                      told in the next event whether it matched. Without expected_password \
                      nothing is verified and the answer proves nothing."
            .to_string(),
        parameters: vec![
            required(
                "eap_type",
                "string|number",
                "'md5-challenge' (4), 'tls' (13) or 'peap' (25). TLS and PEAP send only the \
                 Start flag: NetGet carries no TLS handshake.",
            ),
            p(
                "expected_password",
                "string",
                "MD5-Challenge only: the password this identity should hold. NetGet computes \
                 MD5(identifier || password || challenge) per RFC 1994 §2.2 and compares it \
                 with the supplicant's answer, reporting the result as md5_verification.",
            ),
        ],
        example: json!({
            "type": "send_eap_request_method",
            "eap_type": "md5-challenge",
            "expected_password": "hunter2"
        }),
        log_template: None,
    }
}

fn send_eap_notification_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_eap_notification".to_string(),
        description: "Send a displayable message to the supplicant (EAP-Request/Notification, \
                      RFC 3748 §5.2). It is not a decision and does not end the exchange."
            .to_string(),
        parameters: vec![required(
            "text",
            "string",
            "Message to display, e.g. 'Guest access requires registration'",
        )],
        example: json!({
            "type": "send_eap_notification",
            "text": "Contact IT to register this device"
        }),
        log_template: None,
    }
}

fn send_eap_success_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_eap_success".to_string(),
        description: "ADMIT THIS DEVICE. Sends EAP-Success, which opens the switch port and \
                      puts the supplicant on the network. Use it only when the identity is \
                      known AND has actually proved itself — for MD5-Challenge that means \
                      md5_verification was 'verified'. An identity on its own is a claim, not \
                      evidence. There is no way to send this frame by accident: it is refused \
                      unless the session has already produced an identity, and the server \
                      never synthesises it on any error, timeout or fallback path."
            .to_string(),
        parameters: vec![],
        example: json!({ "type": "send_eap_success" }),
        log_template: None,
    }
}

fn send_eap_failure_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_eap_failure".to_string(),
        description: "DENY THIS DEVICE. Sends EAP-Failure and leaves the port unauthorized. \
                      This is the right answer whenever the identity is unknown, the proof \
                      failed, or policy forbids it — and the server records it as YOUR \
                      decision, distinct from the EAP-Failure it sends on its own when you \
                      answer nothing at all."
            .to_string(),
        parameters: vec![],
        example: json!({ "type": "send_eap_failure" }),
        log_template: None,
    }
}

// ---------------------------------------------------------------------------
// Protocol / Server impls
// ---------------------------------------------------------------------------

impl Protocol for EapolProtocol {
    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
        Vec::new()
    }

    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![
            send_eap_request_identity_action(),
            send_eap_request_method_action(),
            send_eap_notification_action(),
            send_eap_success_action(),
            send_eap_failure_action(),
        ]
    }

    fn protocol_name(&self) -> &'static str {
        "EAPOL"
    }

    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            EAPOL_START_EVENT.clone(),
            EAPOL_IDENTITY_RESPONSE_EVENT.clone(),
            EAPOL_METHOD_RESPONSE_EVENT.clone(),
            EAPOL_LOGOFF_EVENT.clone(),
        ]
    }

    fn stack_name(&self) -> &'static str {
        "ETH>EAPOL>EAP"
    }

    fn keywords(&self) -> Vec<&'static str> {
        // Narrow on purpose. "auth" or "802.1" would hijack keyword resolution from radius
        // and from every other authentication protocol in the registry.
        vec![
            "eapol",
            "802.1x",
            "8021x",
            "dot1x",
            "port authentication",
            "network access control",
        ]
    }

    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
        // Both halves are needed because this protocol has two transports: `transport: "raw"`
        // uses the interface, `transport: "udp"` uses host and port. `BindingDefaults`'
        // constructors each fill only one half, so the struct is built directly.
        Some(crate::protocol::BindingDefaults {
            mac_address: None,
            interface: Some(DEFAULT_LOOPBACK_INTERFACE.to_string()),
            host: Some("127.0.0.1".to_string()),
            port: Some(0),
        })
    }

    fn startup_privilege_requirement(
        &self,

        startup_params: Option<&serde_json::Value>,
    ) -> crate::protocol::metadata::PrivilegeRequirement {
        // The UDP test transport carries the same frames over an ordinary socket.

        crate::llm::actions::protocol_trait::unless_udp_transport(
            self.metadata().privilege_requirement,
            startup_params,
        )
    }

    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::{
            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
        };

        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            // Answers on failure: an authentication exchange the model could not decide gets
            // EAP-Failure, which leaves the port unauthorized. EAPOL-Logoff stays silent because
            // 802.1X defines no reply to it and the port is already de-authorized.
            .answers_on_failure()
            // libpcap capture/injection, never a SOCK_RAW. RawSockets would refuse to start
            // on a host with /dev/bpf* access but no root — the "don't claim more than you
            // need" rule that ospf got wrong by declaring Root when it wanted CAP_NET_RAW.
            // `arp`, `datalink`, `isis`, `lldp`, `stp` and `cdp` all declare this variant.
            //
            // The gate is per-protocol and `server_startup` evaluates it *before* reading
            // startup parameters, so `open_server` is refused on an unprivileged host even
            // when `transport: "udp"` is requested. That is why
            // `tests/server/eapol/e2e_test.rs` builds a `SpawnContext` and calls
            // `Server::spawn` directly, as `bluetooth_ble_beacon` and `lldp` do. Declaring
            // `None` to dodge the gate is the tempting fix and the wrong one: the transport
            // anyone actually deploys is raw Ethernet, and the metadata has to say so.
            .privilege_requirement(PrivilegeRequirement::PacketCapture)
            .implementation(
                "Hand-rolled IEEE 802.1X-2004 §11.3 / RFC 3748 codec (src/server/eapol/codec.rs) \
                 implementing the authenticator (switch-port) role: EAPOL-Start, EAPOL-Logoff, \
                 EAP-Request/Identity, EAP-Request/Notification, EAP-Request/MD5-Challenge, \
                 EAP-Success and EAP-Failure, with the EAP identifier echoed per RFC 3748 §4.2. \
                 MD5-Challenge is fully performed by NetGet: the challenge is generated here and \
                 the RFC 1994 §2.2 digest is verified here (constant-time) using the md-5 crate, \
                 the same one src/server/radius uses, so the model is never asked for \
                 cryptographic material. NOT implemented: EAP-TLS, PEAP and MSCHAPv2 as \
                 methods (TLS/PEAP send only the RFC 5216 Start flag and no handshake is carried), \
                 EAPOL-Key / MKA, RADIUS pass-through, and VLAN assignment. Two transports: raw \
                 Ethernet on EtherType 0x888E via libpcap, and a UDP test transport carrying the \
                 same EAPOL frames prefixed by the supplicant's 6-octet MAC.",
            )
            .llm_control(
                "The model makes the admission decision. It sees the claimed identity, the \
                 supplicant MAC and — for MD5-Challenge — NetGet's own verdict on the digest, \
                 and answers with EAP-Success (admit), EAP-Failure (deny), a further method \
                 challenge, or a notification.",
            )
            .e2e_testing(
                "Codec pinned byte-for-byte against literal IEEE 802.1X / RFC 3748 frames and \
                 the RFC 1321 §A.5 MD5 digest suite, with no network and no LLM. The full \
                 event -> LLM -> action -> frame path is driven end to end over the UDP test \
                 transport with a mocked model, including the fail-closed regressions. NO \
                 third-party supplicant has ever spoken to this server, and the raw-Ethernet \
                 transport has never been executed — see the module CLAUDE.md for the exact \
                 path to changing that.",
            )
            .notes(
                "EXPERIMENTAL, and precisely: the codec and the fail-closed logic are proven by \
                 tests; the raw-Ethernet transport is unproven and has never run, because it \
                 needs packet-capture privilege this environment does not have. FAILS CLOSED: \
                 no LLM answer, an unusable answer, an action that fails to encode, or an LLM \
                 error all produce EAP-Failure, logged decision=fail_closed_* and never \
                 decision=model_reject. EAP-Success is emitted by exactly one function, reached \
                 only from an explicit send_eap_success on a session that already produced an \
                 identity.",
            )
            .max_inbound_bytes(crate::server::eapol::MAX_FRAME_LEN)
            .build()
    }

    fn description(&self) -> &'static str {
        "EAPOL / 802.1X port-based network access control authenticator (IEEE 802.1X, RFC 3748)"
    }

    fn example_prompt(&self) -> &'static str {
        "act as an 802.1X authenticator on en0 via eapol; challenge every supplicant with \
         MD5 and admit alice with password hunter2, deny everyone else"
    }

    fn group_name(&self) -> &'static str {
        "Network"
    }

    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![
            ParameterDefinition {
                name: "transport".to_string(),
                type_hint: "string".to_string(),
                description:
                    "'raw' (default) speaks real EAPOL on EtherType 0x888E over the chosen \
                     interface and needs packet-capture privilege. 'udp' is a test transport: \
                     the same EAPOL frames over a UDP socket, each datagram prefixed with the \
                     supplicant's 6-octet MAC address, so the whole authenticator can be \
                     exercised unprivileged."
                        .to_string(),
                required: false,
                example: json!("raw"),
                default: None,
            },
            ParameterDefinition {
                name: "eapol_version".to_string(),
                type_hint: "number".to_string(),
                description:
                    "EAPOL protocol version to send: 1 (802.1X-2001), 2 (802.1X-2004, default) \
                     or 3 (802.1X-2010). Replies otherwise echo the version the supplicant used."
                        .to_string(),
                required: false,
                example: json!(2),
                default: None,
            },
        ]
    }

    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        use crate::llm::actions::StartupExamples;

        StartupExamples::new(
            // LLM mode
            json!({
                "type": "open_server",
                "base_stack": "eapol",
                "interface": "en0",
                "startup_params": { "transport": "raw" },
                "instruction": "Challenge every supplicant with MD5-Challenge. Admit alice \
                                only if her password hunter2 verifies. Deny everyone else."
            }),
            // Script mode
            json!({
                "type": "open_server",
                "base_stack": "eapol",
                "interface": "en0",
                "event_handlers": [{
                    "event_pattern": "eapol_start",
                    "handler": {
                        "type": "static",
                        "actions": [{"type": "send_eap_request_identity"}]
                    }
                }, {
                    "event_pattern": "eapol_identity_response",
                    "handler": {
                        "type": "script",
                        "language": "python",
                        "code": "respond([{'type': 'send_eap_request_method', 'eap_type': 'md5-challenge', 'expected_password': 'hunter2'}] if event['identity'] == 'alice' else [{'type': 'send_eap_failure'}])"
                    }
                }, {
                    "event_pattern": "eapol_method_response",
                    "handler": {
                        "type": "script",
                        "language": "python",
                        "code": "respond([{'type': 'send_eap_success'}] if event.get('md5_verified') else [{'type': 'send_eap_failure'}])"
                    }
                }]
            }),
            // Static mode
            json!({
                "type": "open_server",
                "base_stack": "eapol",
                "interface": "en0",
                "event_handlers": [{
                    "event_pattern": "*",
                    "handler": {
                        "type": "static",
                        "actions": [{"type": "send_eap_failure"}]
                    }
                }]
            }),
        )
    }
}

impl Server for EapolProtocol {
    fn spawn(
        &self,
        ctx: crate::protocol::SpawnContext,
    ) -> std::pin::Pin<
        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
    > {
        Box::pin(async move { super::EapolServer::spawn_with_llm_actions(ctx).await })
    }

    /// Encode exactly the frame a named action asked for.
    ///
    /// Read the module header before changing anything here. In particular: there is no
    /// wildcard arm that produces a frame, `send_eap_success` is the only path to
    /// [`codec::eapol_eap_success_frame`], and that path is gated on `identity_established`.
    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
        let action_type = action
            .get("type")
            .and_then(|v| v.as_str())
            .context("Missing 'type' field in action")?;

        // Resolve the name before the context, so an action this protocol does not have is
        // reported as *unknown* rather than as a missing request. Both are refusals, but only
        // one of them tells a model it guessed a verb that does not exist — and
        // `tests/event_action_declarations_test.rs` detects an unexecutable advertised name
        // by exactly that wording. Taking the context first made the `_` arm unreachable on a
        // registry instance and hid the difference.
        if !matches!(
            action_type,
            "send_eap_request_identity"
                | "send_eap_request_method"
                | "send_eap_notification"
                | "send_eap_success"
                | "send_eap_failure"
        ) {
            return Err(anyhow::anyhow!("Unknown EAPOL action: {}", action_type));
        }

        // Every remaining arm needs the request context, so take it once: a registry instance
        // fails here rather than half-building a frame with an invented identifier.
        let ctx = self.ctx()?;

        match action_type {
            "send_eap_request_identity" => Ok(ActionResult::Output(
                codec::eapol_wrap_eap(
                    ctx.eapol_version,
                    &codec::eap_request_identity(ctx.request_identifier),
                )
                .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
            )),

            "send_eap_request_method" => {
                let eap_type = method_type_value(
                    action
                        .get("eap_type")
                        .context("send_eap_request_method requires 'eap_type'")?,
                )?;

                let eap = match eap_type {
                    codec::EAP_TYPE_MD5_CHALLENGE => codec::eap_request_md5_challenge(
                        ctx.request_identifier,
                        &ctx.md5_challenge,
                        AUTHENTICATOR_NAME,
                    ),
                    other => codec::eap_request_tls_start(ctx.request_identifier, other),
                }
                .map_err(|e| anyhow::anyhow!("Failed to encode EAP-Request: {}", e))?;

                Ok(ActionResult::Output(
                    codec::eapol_wrap_eap(ctx.eapol_version, &eap)
                        .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
                ))
            }

            "send_eap_notification" => {
                let text = required_text(&action, "text")?;
                if text.is_empty() {
                    return Err(anyhow::anyhow!(
                        "send_eap_notification 'text' must not be empty: an empty notification \
                         tells the supplicant nothing and still costs a round trip"
                    ));
                }
                let eap = codec::eap_request_notification(ctx.request_identifier, &text)
                    .map_err(|e| anyhow::anyhow!("Failed to encode EAP-Notification: {}", e))?;
                Ok(ActionResult::Output(
                    codec::eapol_wrap_eap(ctx.eapol_version, &eap)
                        .map_err(|e| anyhow::anyhow!("Failed to encode EAPOL frame: {}", e))?,
                ))
            }

            // ---------------------------------------------------------------
            // The admission decision. The only reachable call to
            // codec::eapol_eap_success_frame in the whole crate.
            // ---------------------------------------------------------------
            "send_eap_success" => {
                if !ctx.identity_established {
                    return Err(anyhow::anyhow!(
                        "Refusing to send EAP-Success: this session has not produced an \
                         EAP-Response/Identity, so there is no identity to admit. Send \
                         send_eap_request_identity first."
                    ));
                }
                Ok(ActionResult::Output(codec::eapol_eap_success_frame(
                    ctx.eapol_version,
                    ctx.result_identifier,
                )))
            }

            "send_eap_failure" => Ok(ActionResult::Output(codec::eapol_eap_failure_frame(
                ctx.eapol_version,
                ctx.result_identifier,
            ))),

            // Unreachable: the guard above already rejected every other name. Kept as an
            // error rather than a panic, because "the two lists drifted apart" must not be a
            // way to crash a running authenticator.
            other => Err(anyhow::anyhow!("Unknown EAPOL action: {}", other)),
        }
    }
}