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;
#[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";
pub const AUTHENTICATOR_NAME: &str = "netget";
pub const TRANSPORT_RAW: &str = "raw";
pub const TRANSPORT_UDP: &str = "udp";
pub struct EapolProtocol {
request: Option<RequestContext>,
}
#[derive(Clone, Debug)]
pub struct RequestContext {
pub eapol_version: u8,
pub request_identifier: u8,
pub result_identifier: u8,
pub identity_established: bool,
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) }
}
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.",
)
}
}
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))
}
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![
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(),
])
});
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,
}
}
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> {
vec![
"eapol",
"802.1x",
"8021x",
"dot1x",
"port authentication",
"network access control",
]
}
fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
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 {
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()
.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(
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."
}),
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'}])"
}
}]
}),
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 })
}
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")?;
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));
}
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))?,
))
}
"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,
))),
other => Err(anyhow::anyhow!("Unknown EAPOL action: {}", other)),
}
}
}