Skip to main content

netget/server/ldap/
actions.rs

1//! LDAP protocol actions implementation.
2//!
3//! The directory is entirely LLM-supplied: nothing here stores an entry, an attribute or a
4//! password. A bind is granted or refused by the model; a search returns whatever entries the
5//! model names; an add, modify or delete is acknowledged by the model and changes nothing on
6//! this side, because there is nothing on this side to change.
7//!
8//! Actions carry structured entries (`{"dn": ..., "attributes": {...}}`), never bytes: the
9//! BER encoding of every response is built here from those fields.
10
11use crate::llm::actions::{
12    protocol_trait::{ActionResult, Protocol, Server},
13    ActionDefinition, Parameter,
14};
15use crate::protocol::log_template::LogTemplate;
16use crate::protocol::EventType;
17use crate::state::app_state::AppState;
18use anyhow::{Context, Result};
19use serde_json::json;
20use std::sync::LazyLock;
21use tracing::debug;
22
23/// LDAP protocol action handler
24pub struct LdapProtocol;
25
26/// Read the model's `result_code`, refusing anything the one-byte ENUMERATED cannot carry.
27///
28/// This was `as_u64()… as u8`, which wraps in silence, and for LDAP the wrap lands exactly on
29/// the worst value: **`result_code: 256` encodes `0`, and `0` is `success`**. Every refusal
30/// the model can express is a multiple of 256 away from telling the client the operation
31/// succeeded — a directory asserting a bind, a write or a search completed when it did not.
32///
33/// Refusing beats clamping: 255 is not what the model asked for either, and the message is
34/// what the repair loop reads. RFC 4511 §4.1.9 makes resultCode extensible, so the honest
35/// bound is what this encoder can put on the wire rather than a list of named codes.
36fn parse_result_code(action: &serde_json::Value, default: u8) -> Result<u8> {
37    let value = match action.get("result_code") {
38        None => return Ok(default),
39        Some(v) if v.is_null() => return Ok(default),
40        Some(v) => v,
41    };
42    let raw = value
43        .as_u64()
44        .with_context(|| format!("'result_code' must be a number, got {value}"))?;
45    if raw > u8::MAX as u64 {
46        return Err(anyhow::anyhow!(
47            "'result_code' {raw} does not fit the LDAP resultCode this server encodes \
48             (0-255). 0 is success, 32 noSuchObject, 49 invalidCredentials, 50 \
49             insufficientAccessRights, 68 entryAlreadyExists."
50        ));
51    }
52    Ok(raw as u8)
53}
54
55/// Read the model's `message_id`, refusing anything outside LDAP's `INTEGER (0 .. maxInt)`.
56///
57/// `as i64 … as i32` wraps, so `4294967297` becomes `1` and the response is correlated with
58/// whichever request happened to carry that id. A client matches replies by messageID alone,
59/// so a wrapped one is answered to the wrong question or to none at all.
60fn parse_message_id(action: &serde_json::Value) -> Result<i32> {
61    let value = match action.get("message_id") {
62        None => return Ok(1),
63        Some(v) if v.is_null() => return Ok(1),
64        Some(v) => v,
65    };
66    let raw = value
67        .as_i64()
68        .with_context(|| format!("'message_id' must be a number, got {value}"))?;
69    if !(0..=i32::MAX as i64).contains(&raw) {
70        return Err(anyhow::anyhow!(
71            "'message_id' {raw} is outside LDAP's INTEGER (0 .. maxInt) range of \
72             0-2147483647. Echo the message_id from the triggering event so the client can \
73             match the reply to its request."
74        ));
75    }
76    Ok(raw as i32)
77}
78
79impl LdapProtocol {
80    pub fn new() -> Self {
81        Self
82    }
83
84    fn execute_ldap_bind_response(&self, action: serde_json::Value) -> Result<ActionResult> {
85        let message_id = parse_message_id(&action)?;
86
87        let success = action
88            .get("success")
89            .and_then(|v| v.as_bool())
90            .unwrap_or(false);
91
92        let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
93
94        let result_code = if success { 0 } else { 49 }; // 0 = success, 49 = invalidCredentials
95
96        debug!(
97            "LDAP sending bind response: success={}, message={}",
98            success, message
99        );
100
101        let response = encode_bind_response(message_id, result_code, message);
102        Ok(ActionResult::Output(response))
103    }
104
105    fn execute_ldap_search_response(&self, action: serde_json::Value) -> Result<ActionResult> {
106        let message_id = parse_message_id(&action)?;
107
108        let entries = action
109            .get("entries")
110            .and_then(|v| v.as_array())
111            .cloned()
112            .unwrap_or_default();
113
114        let result_code = parse_result_code(&action, 0)?;
115
116        debug!(
117            "LDAP sending search response: {} entries, result_code={}",
118            entries.len(),
119            result_code
120        );
121
122        // Build response with search entries + search done
123        let mut response = Vec::new();
124
125        // Send SearchResultEntry for each entry
126        for entry in entries {
127            let dn = entry.get("dn").and_then(|v| v.as_str()).unwrap_or("");
128
129            let attributes = entry
130                .get("attributes")
131                .and_then(|v| v.as_object())
132                .cloned()
133                .unwrap_or_default();
134
135            response.extend_from_slice(&encode_search_entry(message_id, dn, attributes));
136        }
137
138        // Send SearchResultDone
139        response.extend_from_slice(&encode_search_done(message_id, result_code, ""));
140
141        Ok(ActionResult::Output(response))
142    }
143
144    fn execute_ldap_add_response(&self, action: serde_json::Value) -> Result<ActionResult> {
145        let message_id = parse_message_id(&action)?;
146
147        // Not defaulted to `true`. `success` is only consulted to pick a resultCode when the
148        // model names none, so a forgotten field used to encode resultCode 0 - the directory
149        // telling the client the entry was added. An omission is not an outcome. An explicit
150        // `result_code` still wins on its own, so naming either field works as before.
151        let success = action
152            .get("success")
153            .and_then(|v| v.as_bool())
154            .unwrap_or(false);
155
156        // 68 = entryAlreadyExists
157        let result_code = parse_result_code(&action, if success { 0 } else { 68 })?;
158
159        let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
160
161        debug!(
162            "LDAP sending add response: success={}, result_code={}",
163            success, result_code
164        );
165
166        let response = encode_ldap_result(message_id, 0x69, result_code, message); // 0x69 = AddResponse
167        Ok(ActionResult::Output(response))
168    }
169
170    fn execute_ldap_modify_response(&self, action: serde_json::Value) -> Result<ActionResult> {
171        let message_id = parse_message_id(&action)?;
172
173        // See `execute_ldap_add_response`: an omitted `success` must not encode resultCode 0.
174        let success = action
175            .get("success")
176            .and_then(|v| v.as_bool())
177            .unwrap_or(false);
178
179        // 32 = noSuchObject
180        let result_code = parse_result_code(&action, if success { 0 } else { 32 })?;
181
182        let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
183
184        debug!(
185            "LDAP sending modify response: success={}, result_code={}",
186            success, result_code
187        );
188
189        let response = encode_ldap_result(message_id, 0x67, result_code, message); // 0x67 = ModifyResponse
190        Ok(ActionResult::Output(response))
191    }
192
193    fn execute_ldap_delete_response(&self, action: serde_json::Value) -> Result<ActionResult> {
194        let message_id = parse_message_id(&action)?;
195
196        // See `execute_ldap_add_response`: an omitted `success` must not encode resultCode 0.
197        let success = action
198            .get("success")
199            .and_then(|v| v.as_bool())
200            .unwrap_or(false);
201
202        // 32 = noSuchObject
203        let result_code = parse_result_code(&action, if success { 0 } else { 32 })?;
204
205        let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
206
207        debug!(
208            "LDAP sending delete response: success={}, result_code={}",
209            success, result_code
210        );
211
212        let response = encode_ldap_result(message_id, 0x6B, result_code, message); // 0x6B = DelResponse
213        Ok(ActionResult::Output(response))
214    }
215}
216
217// Implement Protocol trait (common functionality)
218impl Protocol for LdapProtocol {
219    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
220        vec![
221            crate::llm::actions::ParameterDefinition {
222                name: "send_first".to_string(),
223                type_hint: "boolean".to_string(),
224                description: "Unsupported by this server and refused if set to true. LDAP: \
225                                  the client sends a BindRequest or SearchRequest first and \
226                                  every server message is a response framed against one."
227                    .to_string(),
228                required: false,
229                example: serde_json::json!(false),
230                default: None,
231            },
232            crate::llm::actions::ParameterDefinition {
233                name: "first_byte_timeout_secs".to_string(),
234                type_hint: "number".to_string(),
235                description: "Seconds a connected peer may send no LDAP message before the \
236                              server closes it. Default 300, matching the window a `manual` \
237                              rule gives a human to answer one event - a peer is often \
238                              NetGet's own LDAP client, which sends nothing at all until \
239                              someone uses [ send message ]. Lower it for a listener exposed \
240                              to strangers."
241                    .to_string(),
242                required: false,
243                example: serde_json::json!(300),
244                default: Some(serde_json::json!(
245                    super::FIRST_MESSAGE_READ_TIMEOUT.as_secs()
246                )),
247            },
248            crate::llm::actions::ParameterDefinition {
249                name: "idle_timeout_secs".to_string(),
250                type_hint: "number".to_string(),
251                description: "Seconds an established session may send nothing further between \
252                              LDAP messages before the server closes it. Default 900."
253                    .to_string(),
254                required: false,
255                example: serde_json::json!(900),
256                default: Some(serde_json::json!(
257                    super::IDLE_BETWEEN_MESSAGES_TIMEOUT.as_secs()
258                )),
259            },
260        ]
261    }
262    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
263        // LDAP doesn't need async actions for now
264        Vec::new()
265    }
266    /// Every event advertises its own subset of these; this list is the union.
267    ///
268    /// `wait_for_more` used to be declared here and is gone: LDAP messages are framed by their
269    /// BER length, the session reassembles them itself, and the session only ever looks for an
270    /// `ActionResult::Output`. `ActionResult::WaitForMore` was discarded, so the client was
271    /// left waiting for a response that would never come.
272    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
273        vec![
274            ldap_bind_response_action(),
275            ldap_search_response_action(),
276            ldap_add_response_action(),
277            ldap_modify_response_action(),
278            ldap_delete_response_action(),
279            close_connection_action(),
280        ]
281    }
282    fn protocol_name(&self) -> &'static str {
283        "LDAP"
284    }
285    fn get_event_types(&self) -> Vec<EventType> {
286        get_ldap_event_types()
287    }
288    fn stack_name(&self) -> &'static str {
289        "ETH>IP>TCP>LDAP"
290    }
291    fn keywords(&self) -> Vec<&'static str> {
292        vec!["ldap", "directory server"]
293    }
294    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
295        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
296
297        use crate::protocol::metadata::PrivilegeRequirement;
298
299        ProtocolMetadataV2::builder()
300            // Beta: exercised against TWO real, independent clients — ldap3 (Rust) and
301            // OpenLDAP's own `ldapsearch` (C) — covering bind and search, with the write
302            // operations covered by ldap3 alone. Not Stable: Stable additionally wants spec
303            // compliance and scripting support reviewed, which has not been done here.
304            .state(DevelopmentState::Beta)
305            // 389 is below 1024 and is the port every LDAP client defaults to, so the
306            // preflight check in server_startup.rs should fire rather than letting the bind
307            // fail later with a bare EPERM.
308            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(389))
309            .well_known_port(389)
310            .implementation("Manual ASN.1 BER encoding/decoding, no LDAP crate")
311            .llm_control("Bind decisions, search results, add/modify/delete outcomes")
312            .e2e_testing(
313                "TWO independent clients, neither of them the crate this server frames with. \
314                 (1) ldap3 0.11, a Rust LDAP client, in tests/server/ldap/e2e_test.rs: it \
315                 binds, searches, adds, modifies and deletes, and parses entries through its \
316                 own SearchEntry::construct, so a reply it rejected would fail rather than be \
317                 counted as bytes on a socket. It is independent of `ldap3_proto` despite the \
318                 similar name: different project, different authors. \
319                 (2) OpenLDAP's `ldapsearch` -- C, written by the project that wrote the RFC -- \
320                 in tests/server/ldap/real_client_test.rs: it completes a simple bind and a \
321                 search, and the test parses the LDIF it rendered and asserts every value of \
322                 each multi-valued SET OF, an entry long enough to force the long-form BER \
323                 length, the bind diagnosticMessage, and noSuchObject (32) read off \
324                 ldapsearch's own exit status. \
325                 NEITHER is #[ignore]d and NEITHER is skip-gated: ldap3 is a plain optional \
326                 dependency the `ldap` feature turns on, and the ldapsearch test FAILS, naming \
327                 the install command, when no ldapsearch is found. \
328                 The ldapsearch test was verified non-vacuous by breaking the server twice and \
329                 watching the client's rendering change: truncating the attribute SET OF to its \
330                 first value made ldapsearch print one objectClass instead of three, and \
331                 forcing encode_ber_length to the short form made it print no LDIF at all and \
332                 exit 254 with `ldap_result: Local error (-2)`. \
333                 THIS FIELD ONCE CLAIMED `the ldapsearch/ldapadd command-line tools` WHILE \
334                 NOTHING ASSERTING DROVE THEM -- ldapsearch appeared only in tests/eval, the \
335                 real-model harness, which skips unless NETGET_USE_OLLAMA=1 and reports rather \
336                 than asserts. That claim is now true for ldapsearch; `ldapadd` is still driven \
337                 by nothing. \
338                 UNPROVEN: LDAPS and StartTLS, SASL, referrals, and the write operations, which \
339                 are acknowledged without anything changing.",
340            )
341            .notes(
342                "LDAPv3 simple bind only - no SASL, no StartTLS, no LDAPS. Search filters, \
343                 scope and requested-attribute lists are parsed off the wire but not \
344                 evaluated: the model is given the base DN and scope and decides what to \
345                 return. No directory is stored; add/modify/delete are acknowledged, not \
346                 applied. No referrals, no schema validation, no access control beyond what \
347                 the model chooses.",
348            )
349            .max_inbound_bytes(crate::server::ldap::MAX_LDAP_MESSAGE)
350            .build()
351    }
352    fn description(&self) -> &'static str {
353        "LDAP directory server"
354    }
355    fn example_prompt(&self) -> &'static str {
356        "Start an LDAP directory server on port 389"
357    }
358    fn group_name(&self) -> &'static str {
359        "Application"
360    }
361
362    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
363        use crate::llm::actions::StartupExamples;
364        use serde_json::json;
365
366        // Deterministic: answer every search with an empty result set (echoing
367        // the request's message_id), no LLM call.
368        let script = r#"import json, sys
369data = json.load(sys.stdin)
370event = data["event"]
371if data["event_type_id"] == "ldap_search":
372    actions = [{"type": "ldap_search_response",
373                "message_id": event.get("message_id", 1),
374                "entries": []}]
375else:
376    actions = []
377print(json.dumps({"actions": actions}))"#;
378
379        StartupExamples::new(
380            // LLM mode: LLM handles all LDAP responses intelligently
381            json!({
382                "type": "open_server",
383                "port": 389,
384                "base_stack": "ldap",
385                "instruction": "LDAP directory server handling bind and search operations"
386            }),
387            // Script mode: Code-based deterministic responses
388            json!({
389                "type": "open_server",
390                "port": 389,
391                "base_stack": "ldap",
392                "event_handlers": [{
393                    "event_pattern": "ldap_search",
394                    "handler": {
395                        "type": "script",
396                        "language": "python",
397                        "code": script
398                    }
399                }]
400            }),
401            // Static mode: Fixed responses
402            json!({
403                "type": "open_server",
404                "port": 389,
405                "base_stack": "ldap",
406                "event_handlers": [{
407                    "event_pattern": "ldap_bind",
408                    "handler": {
409                        "type": "static",
410                        "actions": [{
411                            "type": "ldap_bind_response",
412                            "message_id": 1,
413                            "success": true
414                        }]
415                    }
416                }]
417            }),
418        )
419    }
420}
421
422// Implement Server trait (server-specific functionality)
423impl Server for LdapProtocol {
424    fn spawn(
425        &self,
426        ctx: crate::protocol::SpawnContext,
427    ) -> std::pin::Pin<
428        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
429    > {
430        Box::pin(async move {
431            use crate::server::ldap::LdapServer;
432            // `send_first` stays declared so a caller that passes `false` still validates - an
433            // undeclared key is refused outright - but `true` is refused rather than ignored.
434            // LDAP: the client sends a BindRequest or a SearchRequest first and every server
435            // message is a response framed against one, so there is nothing for this to turn
436            // on.
437            //
438            // This was `let _send_first = …`: read, then dropped on the floor. The September
439            // sweep that fixed the same defect in seven other servers matched on a discarded
440            // `_send_first` *parameter* and missed this one, because ldap discards it a layer
441            // later, in a local binding.
442            let send_first = ctx
443                .startup_params
444                .as_ref()
445                .map(|p| p.get_optional_bool("send_first"))
446                .transpose()?
447                .flatten()
448                .unwrap_or(false);
449            if send_first {
450                return Err(anyhow::anyhow!(
451                    "send_first is not supported by the LDAP server: the client sends a \
452                     BindRequest or SearchRequest first and every server message is a \
453                     response framed against one."
454                ));
455            }
456
457            // Both read bounds are tunable because their right value is a property of who is on
458            // the other end, which only the operator knows. The defaults serve NetGet's own
459            // client waiting on a human at `[ send message ]`; a listener exposed to strangers
460            // wants the first one much lower.
461            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
462                Ok(ctx
463                    .startup_params
464                    .as_ref()
465                    .map(|p| p.get_optional_u64(name))
466                    .transpose()?
467                    .flatten())
468            };
469            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
470            let idle_timeout_secs = secs("idle_timeout_secs")?;
471
472            LdapServer::spawn_with_llm_actions(
473                ctx.legacy_listen_addr(),
474                ctx.llm_client,
475                ctx.state,
476                ctx.status_tx,
477                ctx.server_id,
478                first_byte_timeout_secs,
479                idle_timeout_secs,
480            )
481            .await
482        })
483    }
484    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
485        let action_type = action
486            .get("type")
487            .and_then(|v| v.as_str())
488            .context("Missing 'type' field in action")?;
489
490        match action_type {
491            "ldap_bind_response" => self.execute_ldap_bind_response(action),
492            "ldap_search_response" => self.execute_ldap_search_response(action),
493            "ldap_add_response" => self.execute_ldap_add_response(action),
494            "ldap_modify_response" => self.execute_ldap_modify_response(action),
495            "ldap_delete_response" => self.execute_ldap_delete_response(action),
496            "close_connection" => Ok(ActionResult::CloseConnection),
497            _ => Err(anyhow::anyhow!("Unknown LDAP action: {}", action_type)),
498        }
499    }
500}
501
502// ============================================================================
503// Action Definitions
504// ============================================================================
505
506fn ldap_bind_response_action() -> ActionDefinition {
507    ActionDefinition {
508        name: "ldap_bind_response".to_string(),
509        description: "Respond to LDAP bind (authentication) request".to_string(),
510        parameters: vec![
511            Parameter {
512                name: "message_id".to_string(),
513                type_hint: "number".to_string(),
514                description: "LDAP message ID from the request".to_string(),
515                required: true,
516            },
517            Parameter {
518                name: "success".to_string(),
519                type_hint: "boolean".to_string(),
520                description: "Whether bind was successful".to_string(),
521                required: true,
522            },
523            Parameter {
524                name: "message".to_string(),
525                type_hint: "string".to_string(),
526                description: "Optional diagnostic message".to_string(),
527                required: false,
528            },
529        ],
530        example: json!({
531            "type": "ldap_bind_response",
532            "message_id": 1,
533            "success": true,
534            "message": "Bind successful"
535        }),
536        log_template: Some(
537            LogTemplate::new()
538                .with_info("-> LDAP bind: {success}")
539                .with_debug("LDAP ldap_bind_response: success={success}"),
540        ),
541    }
542}
543
544fn ldap_search_response_action() -> ActionDefinition {
545    ActionDefinition {
546        name: "ldap_search_response".to_string(),
547        description: "Answer an LDAP search with the entries the instruction says exist \
548                      under the request's base_dn - and only those. Every entry's dn ends \
549                      with the base_dn the client searched. When nothing exists there, send \
550                      \"entries\": [] with result_code 0: a search that finds no one is a \
551                      success, not an error. The example's entry is a stand-in for the shape; \
552                      never send it as data."
553            .to_string(),
554        parameters: vec![
555            Parameter {
556                name: "message_id".to_string(),
557                type_hint: "number".to_string(),
558                description: "LDAP message ID from the request".to_string(),
559                required: true,
560            },
561            Parameter {
562                name: "entries".to_string(),
563                type_hint: "array".to_string(),
564                description: "The matching entries, each {\"dn\": \"cn=...,<base_dn>\", \
565                              \"attributes\": {\"name\": [\"value\", ...]}}. Every attribute \
566                              value is a list of strings. [] when nothing matches."
567                    .to_string(),
568                required: true,
569            },
570            Parameter {
571                name: "result_code".to_string(),
572                type_hint: "number".to_string(),
573                description: "LDAP result code (0 = success, default: 0)".to_string(),
574                required: false,
575            },
576        ],
577        example: json!({
578            "type": "ldap_search_response",
579            "message_id": 2,
580            "entries": [
581                {
582                    "dn": "cn=Example Person,ou=example,dc=example,dc=org",
583                    "attributes": {
584                        "cn": ["Example Person"],
585                        "mail": ["person@example.org"],
586                        "objectClass": ["person", "inetOrgPerson"]
587                    }
588                }
589            ],
590            "result_code": 0
591        }),
592        log_template: Some(
593            LogTemplate::new()
594                .with_info("-> LDAP search: {entries_len} entries")
595                .with_debug("LDAP ldap_search_response: {entries_len} entries"),
596        ),
597    }
598}
599
600fn ldap_add_response_action() -> ActionDefinition {
601    ActionDefinition {
602        name: "ldap_add_response".to_string(),
603        description: "Respond to LDAP add (create entry) request".to_string(),
604        parameters: vec![
605            Parameter {
606                name: "message_id".to_string(),
607                type_hint: "number".to_string(),
608                description: "LDAP message ID from the request".to_string(),
609                required: true,
610            },
611            Parameter {
612                name: "success".to_string(),
613                type_hint: "boolean".to_string(),
614                description: "Whether add was successful".to_string(),
615                required: true,
616            },
617            Parameter {
618                name: "result_code".to_string(),
619                type_hint: "number".to_string(),
620                description: "LDAP result code (0 = success, 68 = entryAlreadyExists)".to_string(),
621                required: false,
622            },
623            Parameter {
624                name: "message".to_string(),
625                type_hint: "string".to_string(),
626                description: "Optional diagnostic message".to_string(),
627                required: false,
628            },
629        ],
630        example: json!({
631            "type": "ldap_add_response",
632            "message_id": 3,
633            "success": true,
634            "result_code": 0,
635            "message": "Entry added successfully"
636        }),
637        log_template: Some(
638            LogTemplate::new()
639                .with_info("-> LDAP add: {success}")
640                .with_debug("LDAP ldap_add_response: success={success}"),
641        ),
642    }
643}
644
645fn ldap_modify_response_action() -> ActionDefinition {
646    ActionDefinition {
647        name: "ldap_modify_response".to_string(),
648        description: "Respond to LDAP modify (update entry) request".to_string(),
649        parameters: vec![
650            Parameter {
651                name: "message_id".to_string(),
652                type_hint: "number".to_string(),
653                description: "LDAP message ID from the request".to_string(),
654                required: true,
655            },
656            Parameter {
657                name: "success".to_string(),
658                type_hint: "boolean".to_string(),
659                description: "Whether modify was successful".to_string(),
660                required: true,
661            },
662            Parameter {
663                name: "result_code".to_string(),
664                type_hint: "number".to_string(),
665                description: "LDAP result code (0 = success, 32 = noSuchObject)".to_string(),
666                required: false,
667            },
668            Parameter {
669                name: "message".to_string(),
670                type_hint: "string".to_string(),
671                description: "Optional diagnostic message".to_string(),
672                required: false,
673            },
674        ],
675        example: json!({
676            "type": "ldap_modify_response",
677            "message_id": 4,
678            "success": true,
679            "result_code": 0,
680            "message": "Entry modified successfully"
681        }),
682        log_template: Some(
683            LogTemplate::new()
684                .with_info("-> LDAP modify: {success}")
685                .with_debug("LDAP ldap_modify_response: success={success}"),
686        ),
687    }
688}
689
690fn ldap_delete_response_action() -> ActionDefinition {
691    ActionDefinition {
692        name: "ldap_delete_response".to_string(),
693        description: "Respond to LDAP delete (remove entry) request".to_string(),
694        parameters: vec![
695            Parameter {
696                name: "message_id".to_string(),
697                type_hint: "number".to_string(),
698                description: "LDAP message ID from the request".to_string(),
699                required: true,
700            },
701            Parameter {
702                name: "success".to_string(),
703                type_hint: "boolean".to_string(),
704                description: "Whether delete was successful".to_string(),
705                required: true,
706            },
707            Parameter {
708                name: "result_code".to_string(),
709                type_hint: "number".to_string(),
710                description: "LDAP result code (0 = success, 32 = noSuchObject)".to_string(),
711                required: false,
712            },
713            Parameter {
714                name: "message".to_string(),
715                type_hint: "string".to_string(),
716                description: "Optional diagnostic message".to_string(),
717                required: false,
718            },
719        ],
720        example: json!({
721            "type": "ldap_delete_response",
722            "message_id": 5,
723            "success": true,
724            "result_code": 0,
725            "message": "Entry deleted successfully"
726        }),
727        log_template: Some(
728            LogTemplate::new()
729                .with_info("-> LDAP delete: {success}")
730                .with_debug("LDAP ldap_delete_response: success={success}"),
731        ),
732    }
733}
734
735fn close_connection_action() -> ActionDefinition {
736    ActionDefinition {
737        name: "close_connection".to_string(),
738        description: "Close the LDAP connection".to_string(),
739        parameters: vec![],
740        example: json!({
741            "type": "close_connection"
742        }),
743        log_template: Some(
744            LogTemplate::new()
745                .with_info("LDAP connection closed")
746                .with_debug("LDAP close_connection"),
747        ),
748    }
749}
750
751// ============================================================================
752// Action Constants
753// ============================================================================
754
755pub static LDAP_BIND_RESPONSE_ACTION: LazyLock<ActionDefinition> =
756    LazyLock::new(|| ldap_bind_response_action());
757pub static LDAP_SEARCH_RESPONSE_ACTION: LazyLock<ActionDefinition> =
758    LazyLock::new(|| ldap_search_response_action());
759pub static LDAP_ADD_RESPONSE_ACTION: LazyLock<ActionDefinition> =
760    LazyLock::new(|| ldap_add_response_action());
761pub static LDAP_MODIFY_RESPONSE_ACTION: LazyLock<ActionDefinition> =
762    LazyLock::new(|| ldap_modify_response_action());
763pub static LDAP_DELETE_RESPONSE_ACTION: LazyLock<ActionDefinition> =
764    LazyLock::new(|| ldap_delete_response_action());
765pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
766    LazyLock::new(|| close_connection_action());
767
768// ============================================================================
769// Event Type Constants
770// ============================================================================
771
772/// LDAP bind event - triggered when client attempts to authenticate
773pub static LDAP_BIND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
774    EventType::new(
775        "ldap_bind",
776        "LDAP bind (authentication) request received",
777        json!({
778            "type": "ldap_bind_response",
779            "message_id": 1,
780            "success": true,
781            "message": "Bind successful"
782        }),
783    )
784    .with_alternative_example(json!({
785        "type": "ldap_bind_response",
786        "message_id": 1,
787        "success": false,
788        "message": "Invalid credentials"
789    }))
790    .with_parameters(vec![
791        Parameter {
792            name: "message_id".to_string(),
793            type_hint: "number".to_string(),
794            description: "LDAP message ID".to_string(),
795            required: true,
796        },
797        Parameter {
798            name: "version".to_string(),
799            type_hint: "number".to_string(),
800            description: "LDAP protocol version (typically 3)".to_string(),
801            required: true,
802        },
803        Parameter {
804            name: "dn".to_string(),
805            type_hint: "string".to_string(),
806            description: "Distinguished Name for authentication".to_string(),
807            required: true,
808        },
809        Parameter {
810            name: "password".to_string(),
811            type_hint: "string".to_string(),
812            description: "Password for simple authentication (empty for an anonymous bind)"
813                .to_string(),
814            required: true,
815        },
816        Parameter {
817            name: "auth_type".to_string(),
818            type_hint: "string".to_string(),
819            description: "'simple' or 'sasl'. SASL is not supported: the mechanism is reported \
820                          but no credentials are available, so a SASL bind can only be refused."
821                .to_string(),
822            required: true,
823        },
824    ])
825    .with_actions(vec![
826        LDAP_BIND_RESPONSE_ACTION.clone(),
827        CLOSE_CONNECTION_ACTION.clone(),
828    ])
829    .with_log_template(
830        LogTemplate::new()
831            .with_info("LDAP BIND {client_ip} dn={dn}")
832            .with_debug("LDAP bind v{version} from {client_ip}:{client_port}, dn={dn}")
833            .with_trace("LDAP bind: {json_pretty(.)}"),
834    )
835});
836
837/// LDAP search event - triggered when client performs a directory search
838pub static LDAP_SEARCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
839    EventType::new(
840        "ldap_search",
841        "LDAP search request received",
842        json!({
843            "type": "ldap_search_response",
844            "message_id": 2,
845            "entries": [{
846                "dn": "cn=Example Person,ou=example,dc=example,dc=org",
847                "attributes": {
848                    "cn": ["Example Person"],
849                    "mail": ["person@example.org"],
850                    "objectClass": ["person", "inetOrgPerson"]
851                }
852            }],
853            "result_code": 0
854        }),
855    )
856        .with_alternative_example(json!({
857            "type": "ldap_search_response",
858            "message_id": 2,
859            "entries": [],
860            "result_code": 0
861        }))
862        .with_parameters(vec![
863            Parameter {
864                name: "message_id".to_string(),
865                type_hint: "number".to_string(),
866                description: "LDAP message ID".to_string(),
867                required: true,
868            },
869            Parameter {
870                name: "base_dn".to_string(),
871                type_hint: "string".to_string(),
872                description: "Base DN the client searched. Answer only with entries at or \
873                              under it; if the instruction puts nothing there, answer with \
874                              no entries."
875                    .to_string(),
876                required: true,
877            },
878            Parameter {
879                name: "authenticated".to_string(),
880                type_hint: "boolean".to_string(),
881                description: "Whether client is authenticated".to_string(),
882                required: true,
883            },
884            Parameter {
885                name: "bind_dn".to_string(),
886                type_hint: "string".to_string(),
887                description: "DN of authenticated user (empty if not authenticated)".to_string(),
888                required: true,
889            },
890            Parameter {
891                name: "scope".to_string(),
892                type_hint: "string".to_string(),
893                description: "'base', 'one' or 'sub'. Reported, not enforced - the entries you return are returned as-is.".to_string(),
894                required: true,
895            },
896            Parameter {
897                name: "filter".to_string(),
898                type_hint: "string".to_string(),
899                description: "Search filter in RFC 4515 text form, e.g. '(objectClass=person)'. Reported, not evaluated: decide for yourself which entries match.".to_string(),
900                required: true,
901            },
902            Parameter {
903                name: "attributes".to_string(),
904                type_hint: "array".to_string(),
905                description: "Attribute names the client asked for; empty means all. Reported, not enforced.".to_string(),
906                required: true,
907            },
908        ])
909        .with_actions(vec![
910            LDAP_SEARCH_RESPONSE_ACTION.clone(),
911            CLOSE_CONNECTION_ACTION.clone(),
912        ])
913        .with_log_template(
914            LogTemplate::new()
915                .with_info("LDAP SEARCH {client_ip} base={base_dn}")
916                .with_debug("LDAP search from {client_ip}:{client_port}, base_dn={base_dn}, scope={scope}, filter={filter}")
917                .with_trace("LDAP search: {json_pretty(.)}"),
918        )
919});
920
921/// LDAP add event - triggered when a client asks to create an entry
922///
923/// Nothing is stored: the response tells the client whether the add "succeeded". A server
924/// instruction that accepts adds is claiming the entry exists from then on, and it is the
925/// model's memory that has to make the following search agree.
926pub static LDAP_ADD_EVENT: LazyLock<EventType> = LazyLock::new(|| {
927    EventType::new(
928        "ldap_add",
929        "LDAP add (create entry) request received",
930        json!({
931            "type": "ldap_add_response",
932            "message_id": 3,
933            "success": true,
934            "message": "Entry added"
935        }),
936    )
937    .with_alternative_example(json!({
938        "type": "ldap_add_response",
939        "message_id": 3,
940        "success": false,
941        "result_code": 50,
942        "message": "Insufficient access rights"
943    }))
944    .with_parameters(vec![
945        Parameter {
946            name: "message_id".to_string(),
947            type_hint: "number".to_string(),
948            description: "LDAP message ID".to_string(),
949            required: true,
950        },
951        Parameter {
952            name: "dn".to_string(),
953            type_hint: "string".to_string(),
954            description: "Distinguished Name of the entry to create".to_string(),
955            required: true,
956        },
957        Parameter {
958            name: "attributes".to_string(),
959            type_hint: "object".to_string(),
960            description: "Attributes of the new entry, name -> array of values".to_string(),
961            required: true,
962        },
963        Parameter {
964            name: "authenticated".to_string(),
965            type_hint: "boolean".to_string(),
966            description: "Whether this connection completed a successful bind".to_string(),
967            required: true,
968        },
969        Parameter {
970            name: "bind_dn".to_string(),
971            type_hint: "string".to_string(),
972            description: "DN of authenticated user (empty if not authenticated)".to_string(),
973            required: true,
974        },
975    ])
976    .with_actions(vec![
977        LDAP_ADD_RESPONSE_ACTION.clone(),
978        CLOSE_CONNECTION_ACTION.clone(),
979    ])
980    .with_log_template(
981        LogTemplate::new()
982            .with_info("LDAP ADD {client_ip} dn={dn}")
983            .with_debug("LDAP add from {client_ip}:{client_port}, dn={dn}")
984            .with_trace("LDAP add: {json_pretty(.)}"),
985    )
986});
987
988/// LDAP modify event - triggered when a client asks to change an entry
989pub static LDAP_MODIFY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
990    EventType::new(
991        "ldap_modify",
992        "LDAP modify (update entry) request received",
993        json!({
994            "type": "ldap_modify_response",
995            "message_id": 4,
996            "success": true,
997            "message": "Entry modified"
998        }),
999    )
1000    .with_alternative_example(json!({
1001        "type": "ldap_modify_response",
1002        "message_id": 4,
1003        "success": false,
1004        "result_code": 32,
1005        "message": "No such object"
1006    }))
1007    .with_parameters(vec![
1008        Parameter {
1009            name: "message_id".to_string(),
1010            type_hint: "number".to_string(),
1011            description: "LDAP message ID".to_string(),
1012            required: true,
1013        },
1014        Parameter {
1015            name: "dn".to_string(),
1016            type_hint: "string".to_string(),
1017            description: "Distinguished Name of the entry being modified".to_string(),
1018            required: true,
1019        },
1020        Parameter {
1021            name: "changes".to_string(),
1022            type_hint: "array".to_string(),
1023            description: "Requested changes: [{\"operation\": \"add\"|\"delete\"|\"replace\", \
1024                          \"attribute\": \"mail\", \"values\": [\"a@example.com\"]}]"
1025                .to_string(),
1026            required: true,
1027        },
1028        Parameter {
1029            name: "authenticated".to_string(),
1030            type_hint: "boolean".to_string(),
1031            description: "Whether this connection completed a successful bind".to_string(),
1032            required: true,
1033        },
1034        Parameter {
1035            name: "bind_dn".to_string(),
1036            type_hint: "string".to_string(),
1037            description: "DN of authenticated user (empty if not authenticated)".to_string(),
1038            required: true,
1039        },
1040    ])
1041    .with_actions(vec![
1042        LDAP_MODIFY_RESPONSE_ACTION.clone(),
1043        CLOSE_CONNECTION_ACTION.clone(),
1044    ])
1045    .with_log_template(
1046        LogTemplate::new()
1047            .with_info("LDAP MODIFY {client_ip} dn={dn}")
1048            .with_debug("LDAP modify from {client_ip}:{client_port}, dn={dn}")
1049            .with_trace("LDAP modify: {json_pretty(.)}"),
1050    )
1051});
1052
1053/// LDAP delete event - triggered when a client asks to remove an entry
1054pub static LDAP_DELETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1055    EventType::new(
1056        "ldap_delete",
1057        "LDAP delete (remove entry) request received",
1058        json!({
1059            "type": "ldap_delete_response",
1060            "message_id": 5,
1061            "success": true,
1062            "message": "Entry deleted"
1063        }),
1064    )
1065    .with_alternative_example(json!({
1066        "type": "ldap_delete_response",
1067        "message_id": 5,
1068        "success": false,
1069        "result_code": 32,
1070        "message": "No such object"
1071    }))
1072    .with_parameters(vec![
1073        Parameter {
1074            name: "message_id".to_string(),
1075            type_hint: "number".to_string(),
1076            description: "LDAP message ID".to_string(),
1077            required: true,
1078        },
1079        Parameter {
1080            name: "dn".to_string(),
1081            type_hint: "string".to_string(),
1082            description: "Distinguished Name of the entry to delete".to_string(),
1083            required: true,
1084        },
1085        Parameter {
1086            name: "authenticated".to_string(),
1087            type_hint: "boolean".to_string(),
1088            description: "Whether this connection completed a successful bind".to_string(),
1089            required: true,
1090        },
1091        Parameter {
1092            name: "bind_dn".to_string(),
1093            type_hint: "string".to_string(),
1094            description: "DN of authenticated user (empty if not authenticated)".to_string(),
1095            required: true,
1096        },
1097    ])
1098    .with_actions(vec![
1099        LDAP_DELETE_RESPONSE_ACTION.clone(),
1100        CLOSE_CONNECTION_ACTION.clone(),
1101    ])
1102    .with_log_template(
1103        LogTemplate::new()
1104            .with_info("LDAP DELETE {client_ip} dn={dn}")
1105            .with_debug("LDAP delete from {client_ip}:{client_port}, dn={dn}")
1106            .with_trace("LDAP delete: {json_pretty(.)}"),
1107    )
1108});
1109
1110/// LDAP unbind event - triggered when client closes connection
1111///
1112/// Purely informational: RFC 4511 forbids a response to an unbind, so there is no protocol
1113/// action to offer. `.with_no_actions()` says so explicitly - an empty `.with_actions(vec![])`
1114/// is indistinguishable from a forgotten action list, and `call_llm` treats that as a bug,
1115/// firing a `debug_assert!` that panics the connection task in dev builds.
1116pub static LDAP_UNBIND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1117    EventType::new(
1118        "ldap_unbind",
1119        "LDAP unbind (disconnect) request received",
1120        json!({"type": "show_message", "message": "LDAP client disconnected"}),
1121    )
1122    .with_parameters(vec![Parameter {
1123        name: "bind_dn".to_string(),
1124        type_hint: "string".to_string(),
1125        description: "DN of authenticated user (empty if not authenticated)".to_string(),
1126        required: true,
1127    }])
1128    .with_no_actions()
1129    .with_log_template(
1130        LogTemplate::new()
1131            .with_info("LDAP UNBIND {client_ip}")
1132            .with_debug("LDAP unbind from {client_ip}:{client_port}, bind_dn={bind_dn}")
1133            .with_trace("LDAP unbind: {json_pretty(.)}"),
1134    )
1135});
1136
1137/// Get LDAP event types
1138pub fn get_ldap_event_types() -> Vec<EventType> {
1139    vec![
1140        LDAP_BIND_EVENT.clone(),
1141        LDAP_SEARCH_EVENT.clone(),
1142        LDAP_ADD_EVENT.clone(),
1143        LDAP_MODIFY_EVENT.clone(),
1144        LDAP_DELETE_EVENT.clone(),
1145        LDAP_UNBIND_EVENT.clone(),
1146    ]
1147}
1148
1149// ============================================================================
1150// BER Encoding Helpers
1151// ============================================================================
1152
1153fn encode_ber_length(length: usize) -> Vec<u8> {
1154    if length < 128 {
1155        vec![length as u8]
1156    } else if length < 256 {
1157        vec![0x81, length as u8]
1158    } else if length < 65536 {
1159        vec![0x82, (length >> 8) as u8, length as u8]
1160    } else {
1161        vec![
1162            0x83,
1163            (length >> 16) as u8,
1164            (length >> 8) as u8,
1165            length as u8,
1166        ]
1167    }
1168}
1169
1170fn encode_ber_integer(value: i32) -> Vec<u8> {
1171    let mut result = vec![0x02]; // INTEGER tag
1172
1173    if value >= 0 && value < 128 {
1174        result.push(0x01); // length
1175        result.push(value as u8);
1176    } else {
1177        result.push(0x04); // length (4 bytes)
1178        result.extend_from_slice(&value.to_be_bytes());
1179    }
1180
1181    result
1182}
1183
1184fn encode_ber_string(s: &str) -> Vec<u8> {
1185    let mut result = vec![0x04]; // OCTET STRING tag
1186    let bytes = s.as_bytes();
1187    result.extend_from_slice(&encode_ber_length(bytes.len()));
1188    result.extend_from_slice(bytes);
1189    result
1190}
1191
1192fn encode_ldap_message(msg_id: i32, protocol_op: Vec<u8>) -> Vec<u8> {
1193    let mut content = Vec::new();
1194    content.extend_from_slice(&encode_ber_integer(msg_id));
1195    content.extend_from_slice(&protocol_op);
1196
1197    let mut message = vec![0x30]; // SEQUENCE tag
1198    message.extend_from_slice(&encode_ber_length(content.len()));
1199    message.extend_from_slice(&content);
1200
1201    message
1202}
1203
1204fn encode_bind_response(msg_id: i32, result_code: u8, diagnostic_message: &str) -> Vec<u8> {
1205    let mut bind_resp = Vec::new();
1206
1207    // resultCode (ENUMERATED)
1208    bind_resp.push(0x0A);
1209    bind_resp.push(0x01);
1210    bind_resp.push(result_code);
1211
1212    // matchedDN (empty)
1213    bind_resp.push(0x04);
1214    bind_resp.push(0x00);
1215
1216    // diagnosticMessage
1217    bind_resp.extend_from_slice(&encode_ber_string(diagnostic_message));
1218
1219    // Wrap in BindResponse APPLICATION tag [1]
1220    let mut bind_msg = vec![0x61];
1221    bind_msg.extend_from_slice(&encode_ber_length(bind_resp.len()));
1222    bind_msg.extend_from_slice(&bind_resp);
1223
1224    encode_ldap_message(msg_id, bind_msg)
1225}
1226
1227fn encode_search_entry(
1228    msg_id: i32,
1229    dn: &str,
1230    attributes: serde_json::Map<String, serde_json::Value>,
1231) -> Vec<u8> {
1232    // SearchResultEntry ::= [APPLICATION 4] SEQUENCE {
1233    //     objectName LDAPDN,
1234    //     attributes PartialAttributeList }
1235
1236    let mut entry_content = Vec::new();
1237
1238    // objectName (DN)
1239    entry_content.extend_from_slice(&encode_ber_string(dn));
1240
1241    // attributes (SEQUENCE OF)
1242    let mut attrs_content = Vec::new();
1243    for (attr_name, attr_values) in attributes {
1244        // PartialAttribute ::= SEQUENCE {
1245        //     type AttributeDescription,
1246        //     vals SET OF value AttributeValue }
1247
1248        let mut attr_content = Vec::new();
1249
1250        // type (attribute name)
1251        attr_content.extend_from_slice(&encode_ber_string(&attr_name));
1252
1253        // vals (SET OF)
1254        let mut vals_content = Vec::new();
1255        if let Some(arr) = attr_values.as_array() {
1256            for val in arr {
1257                if let Some(s) = val.as_str() {
1258                    vals_content.extend_from_slice(&encode_ber_string(s));
1259                }
1260            }
1261        }
1262
1263        let mut vals = vec![0x31]; // SET tag
1264        vals.extend_from_slice(&encode_ber_length(vals_content.len()));
1265        vals.extend_from_slice(&vals_content);
1266        attr_content.extend_from_slice(&vals);
1267
1268        // Wrap in SEQUENCE
1269        let mut attr = vec![0x30];
1270        attr.extend_from_slice(&encode_ber_length(attr_content.len()));
1271        attr.extend_from_slice(&attr_content);
1272        attrs_content.extend_from_slice(&attr);
1273    }
1274
1275    let mut attrs = vec![0x30]; // SEQUENCE tag
1276    attrs.extend_from_slice(&encode_ber_length(attrs_content.len()));
1277    attrs.extend_from_slice(&attrs_content);
1278    entry_content.extend_from_slice(&attrs);
1279
1280    // Wrap in SearchResultEntry APPLICATION tag [4]
1281    let mut entry_msg = vec![0x64];
1282    entry_msg.extend_from_slice(&encode_ber_length(entry_content.len()));
1283    entry_msg.extend_from_slice(&entry_content);
1284
1285    encode_ldap_message(msg_id, entry_msg)
1286}
1287
1288fn encode_search_done(msg_id: i32, result_code: u8, diagnostic_message: &str) -> Vec<u8> {
1289    let mut result = Vec::new();
1290
1291    // resultCode (ENUMERATED)
1292    result.push(0x0A);
1293    result.push(0x01);
1294    result.push(result_code);
1295
1296    // matchedDN (empty)
1297    result.push(0x04);
1298    result.push(0x00);
1299
1300    // diagnosticMessage
1301    result.extend_from_slice(&encode_ber_string(diagnostic_message));
1302
1303    // Wrap in SearchResultDone APPLICATION tag [5]
1304    let mut search_msg = vec![0x65];
1305    search_msg.extend_from_slice(&encode_ber_length(result.len()));
1306    search_msg.extend_from_slice(&result);
1307
1308    encode_ldap_message(msg_id, search_msg)
1309}
1310
1311fn encode_ldap_result(
1312    msg_id: i32,
1313    app_tag: u8,
1314    result_code: u8,
1315    diagnostic_message: &str,
1316) -> Vec<u8> {
1317    let mut result = Vec::new();
1318
1319    // resultCode (ENUMERATED)
1320    result.push(0x0A);
1321    result.push(0x01);
1322    result.push(result_code);
1323
1324    // matchedDN (empty)
1325    result.push(0x04);
1326    result.push(0x00);
1327
1328    // diagnosticMessage
1329    result.extend_from_slice(&encode_ber_string(diagnostic_message));
1330
1331    // Wrap in APPLICATION tag
1332    let mut msg = vec![app_tag];
1333    msg.extend_from_slice(&encode_ber_length(result.len()));
1334    msg.extend_from_slice(&result);
1335
1336    encode_ldap_message(msg_id, msg)
1337}