1use 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
23pub struct LdapProtocol;
25
26fn 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
55fn 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 }; 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 let mut response = Vec::new();
124
125 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 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 let success = action
152 .get("success")
153 .and_then(|v| v.as_bool())
154 .unwrap_or(false);
155
156 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); 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 let success = action
175 .get("success")
176 .and_then(|v| v.as_bool())
177 .unwrap_or(false);
178
179 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); 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 let success = action
198 .get("success")
199 .and_then(|v| v.as_bool())
200 .unwrap_or(false);
201
202 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); Ok(ActionResult::Output(response))
214 }
215}
216
217impl 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 Vec::new()
265 }
266 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 .state(DevelopmentState::Beta)
305 .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 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 json!({
382 "type": "open_server",
383 "port": 389,
384 "base_stack": "ldap",
385 "instruction": "LDAP directory server handling bind and search operations"
386 }),
387 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 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
422impl 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 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 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
502fn 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
751pub 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
768pub 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
837pub 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
921pub 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
988pub 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
1053pub 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
1110pub 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
1137pub 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
1149fn 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]; if value >= 0 && value < 128 {
1174 result.push(0x01); result.push(value as u8);
1176 } else {
1177 result.push(0x04); 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]; 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]; 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 bind_resp.push(0x0A);
1209 bind_resp.push(0x01);
1210 bind_resp.push(result_code);
1211
1212 bind_resp.push(0x04);
1214 bind_resp.push(0x00);
1215
1216 bind_resp.extend_from_slice(&encode_ber_string(diagnostic_message));
1218
1219 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 let mut entry_content = Vec::new();
1237
1238 entry_content.extend_from_slice(&encode_ber_string(dn));
1240
1241 let mut attrs_content = Vec::new();
1243 for (attr_name, attr_values) in attributes {
1244 let mut attr_content = Vec::new();
1249
1250 attr_content.extend_from_slice(&encode_ber_string(&attr_name));
1252
1253 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]; 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 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]; 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 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 result.push(0x0A);
1293 result.push(0x01);
1294 result.push(result_code);
1295
1296 result.push(0x04);
1298 result.push(0x00);
1299
1300 result.extend_from_slice(&encode_ber_string(diagnostic_message));
1302
1303 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 result.push(0x0A);
1321 result.push(0x01);
1322 result.push(result_code);
1323
1324 result.push(0x04);
1326 result.push(0x00);
1327
1328 result.extend_from_slice(&encode_ber_string(diagnostic_message));
1330
1331 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}