1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub static LDAP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16 EventType::new(
17 "ldap_connected",
18 "LDAP client successfully connected to server",
19 json!({"type": "bind", "dn": "cn=admin,dc=example,dc=com", "password": "secret"}),
20 )
21 .with_parameters(vec![Parameter {
22 name: "remote_addr".to_string(),
23 type_hint: "string".to_string(),
24 description: "LDAP server address".to_string(),
25 required: true,
26 }])
27});
28
29pub static LDAP_CLIENT_BIND_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
31 EventType::new(
32 "ldap_bind_response",
33 "LDAP bind (authentication) response received",
34 json!({"type": "search", "base_dn": "dc=example,dc=com", "filter": "(objectClass=person)"}),
35 )
36 .with_parameters(vec![
37 Parameter {
38 name: "dn".to_string(),
39 type_hint: "string".to_string(),
40 description: "The DN the bind was for".to_string(),
41 required: true,
42 },
43 Parameter {
44 name: "success".to_string(),
45 type_hint: "boolean".to_string(),
46 description: "Whether bind was successful".to_string(),
47 required: true,
48 },
49 Parameter {
50 name: "message".to_string(),
51 type_hint: "string".to_string(),
52 description: "Response message or error".to_string(),
53 required: false,
54 },
55 ])
56});
57
58pub static LDAP_CLIENT_SEARCH_RESULTS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
60 EventType::new(
61 "ldap_search_results",
62 "LDAP search results received",
63 json!({"type": "wait_for_more"}),
64 )
65 .with_parameters(vec![
66 Parameter {
67 name: "base_dn".to_string(),
68 type_hint: "string".to_string(),
69 description: "The search base this result belongs to".to_string(),
70 required: true,
71 },
72 Parameter {
73 name: "filter".to_string(),
74 type_hint: "string".to_string(),
75 description: "The filter this result belongs to".to_string(),
76 required: true,
77 },
78 Parameter {
79 name: "success".to_string(),
80 type_hint: "boolean".to_string(),
81 description: "Whether the server completed the search (false for e.g. \
82 noSuchObject; `message` then carries the server's result)"
83 .to_string(),
84 required: true,
85 },
86 Parameter {
87 name: "message".to_string(),
88 type_hint: "string".to_string(),
89 description: "The server's result when the search failed".to_string(),
90 required: false,
91 },
92 Parameter {
93 name: "entries".to_string(),
94 type_hint: "array".to_string(),
95 description: "Array of LDAP entries with DN and attributes".to_string(),
96 required: true,
97 },
98 Parameter {
99 name: "count".to_string(),
100 type_hint: "number".to_string(),
101 description: "Number of entries returned".to_string(),
102 required: true,
103 },
104 ])
105});
106
107pub static LDAP_CLIENT_MODIFY_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
109 EventType::new(
110 "ldap_modify_response",
111 "Response to an add, modify or delete",
112 json!({"type": "wait_for_more"}),
113 )
114 .with_parameters(vec![
115 Parameter {
116 name: "operation".to_string(),
117 type_hint: "string".to_string(),
118 description: "Which operation this answers: add, modify or delete".to_string(),
119 required: true,
120 },
121 Parameter {
122 name: "dn".to_string(),
123 type_hint: "string".to_string(),
124 description: "The entry the operation was for".to_string(),
125 required: true,
126 },
127 Parameter {
128 name: "success".to_string(),
129 type_hint: "boolean".to_string(),
130 description: "Whether modify was successful".to_string(),
131 required: true,
132 },
133 Parameter {
134 name: "message".to_string(),
135 type_hint: "string".to_string(),
136 description: "Response message or error".to_string(),
137 required: false,
138 },
139 ])
140});
141
142pub struct LdapClientProtocol;
144
145impl LdapClientProtocol {
146 pub fn new() -> Self {
147 Self
148 }
149}
150
151impl Protocol for LdapClientProtocol {
153 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
154 vec![
155 ActionDefinition {
156 name: "bind".to_string(),
157 description: "Authenticate to LDAP server with credentials".to_string(),
158 parameters: vec![
159 Parameter {
160 name: "dn".to_string(),
161 type_hint: "string".to_string(),
162 description: "Distinguished Name (DN) to bind as (e.g., 'cn=admin,dc=example,dc=com')".to_string(),
163 required: true,
164 },
165 Parameter {
166 name: "password".to_string(),
167 type_hint: "string".to_string(),
168 description: "Password for authentication".to_string(),
169 required: true,
170 },
171 ],
172 example: json!({
173 "type": "bind",
174 "dn": "cn=admin,dc=example,dc=com",
175 "password": "secret"
176 }),
177 log_template: None,
178 },
179 ActionDefinition {
180 name: "search".to_string(),
181 description: "Search for LDAP entries".to_string(),
182 parameters: vec![
183 Parameter {
184 name: "base_dn".to_string(),
185 type_hint: "string".to_string(),
186 description: "Base DN to start search from (e.g., 'dc=example,dc=com')".to_string(),
187 required: true,
188 },
189 Parameter {
190 name: "filter".to_string(),
191 type_hint: "string".to_string(),
192 description: "LDAP search filter (e.g., '(objectClass=person)', '(cn=john)')".to_string(),
193 required: true,
194 },
195 Parameter {
196 name: "attributes".to_string(),
197 type_hint: "array".to_string(),
198 description: "Attributes to retrieve (e.g., ['cn', 'mail', 'uid'])".to_string(),
199 required: false,
200 },
201 Parameter {
202 name: "scope".to_string(),
203 type_hint: "string".to_string(),
204 description: "Search scope: 'base', 'one', or 'subtree' (default: 'subtree')".to_string(),
205 required: false,
206 },
207 ],
208 example: json!({
209 "type": "search",
210 "base_dn": "dc=example,dc=com",
211 "filter": "(objectClass=person)",
212 "attributes": ["cn", "mail", "uid"],
213 "scope": "subtree"
214 }),
215 log_template: None,
216 },
217 ActionDefinition {
218 name: "add".to_string(),
219 description: "Add a new LDAP entry".to_string(),
220 parameters: vec![
221 Parameter {
222 name: "dn".to_string(),
223 type_hint: "string".to_string(),
224 description: "Distinguished Name of the new entry".to_string(),
225 required: true,
226 },
227 Parameter {
228 name: "attributes".to_string(),
229 type_hint: "object".to_string(),
230 description: "Object with attribute names as keys and arrays of values".to_string(),
231 required: true,
232 },
233 ],
234 example: json!({
235 "type": "add",
236 "dn": "cn=newuser,dc=example,dc=com",
237 "attributes": {
238 "objectClass": ["person", "inetOrgPerson"],
239 "cn": ["newuser"],
240 "sn": ["User"],
241 "mail": ["newuser@example.com"]
242 }
243 }),
244 log_template: None,
245 },
246 ActionDefinition {
247 name: "modify".to_string(),
248 description: "Modify an existing LDAP entry".to_string(),
249 parameters: vec![
250 Parameter {
251 name: "dn".to_string(),
252 type_hint: "string".to_string(),
253 description: "Distinguished Name of the entry to modify".to_string(),
254 required: true,
255 },
256 Parameter {
257 name: "operation".to_string(),
258 type_hint: "string".to_string(),
259 description: "Modification operation: 'add', 'delete', or 'replace'".to_string(),
260 required: true,
261 },
262 Parameter {
263 name: "attribute".to_string(),
264 type_hint: "string".to_string(),
265 description: "Attribute name to modify".to_string(),
266 required: true,
267 },
268 Parameter {
269 name: "values".to_string(),
270 type_hint: "array".to_string(),
271 description: "Array of values for the attribute".to_string(),
272 required: true,
273 },
274 ],
275 example: json!({
276 "type": "modify",
277 "dn": "cn=user,dc=example,dc=com",
278 "operation": "replace",
279 "attribute": "mail",
280 "values": ["newemail@example.com"]
281 }),
282 log_template: None,
283 },
284 ActionDefinition {
285 name: "delete".to_string(),
286 description: "Delete an LDAP entry".to_string(),
287 parameters: vec![
288 Parameter {
289 name: "dn".to_string(),
290 type_hint: "string".to_string(),
291 description: "Distinguished Name of the entry to delete".to_string(),
292 required: true,
293 },
294 ],
295 example: json!({
296 "type": "delete",
297 "dn": "cn=olduser,dc=example,dc=com"
298 }),
299 log_template: None,
300 },
301 ActionDefinition {
302 name: "disconnect".to_string(),
303 description: "Disconnect from the LDAP server".to_string(),
304 parameters: vec![],
305 example: json!({
306 "type": "disconnect"
307 }),
308 log_template: None,
309 },
310 ]
311 }
312 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
313 vec![ActionDefinition {
314 name: "wait_for_more".to_string(),
315 description: "Wait for more data from LDAP server".to_string(),
316 parameters: vec![],
317 example: json!({
318 "type": "wait_for_more"
319 }),
320 log_template: None,
321 }]
322 }
323 fn protocol_name(&self) -> &'static str {
324 "LDAP"
325 }
326 fn get_event_types(&self) -> Vec<EventType> {
327 vec![
328 EventType::new(
329 "ldap_connected",
330 "Triggered when LDAP client connects to server",
331 json!({"type": "wait_for_more"}),
332 ),
333 EventType::new(
334 "ldap_bind_response",
335 "Triggered when LDAP bind response is received",
336 json!({"type": "wait_for_more"}),
337 ),
338 EventType::new(
339 "ldap_search_results",
340 "Triggered when LDAP search results are received",
341 json!({"type": "wait_for_more"}),
342 ),
343 EventType::new(
344 "ldap_modify_response",
345 "Triggered when LDAP modify response is received",
346 json!({"type": "wait_for_more"}),
347 ),
348 ]
349 }
350 fn stack_name(&self) -> &'static str {
351 "ETH>IP>TCP>LDAP"
352 }
353 fn keywords(&self) -> Vec<&'static str> {
354 vec!["ldap", "ldap client", "connect to ldap", "directory"]
355 }
356 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
357 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
358
359 ProtocolMetadataV2::builder()
360 .state(DevelopmentState::Beta)
361 .implementation(
362 "ldap3 (synchronous LdapConn on spawn_blocking). Every operation's response \
363 goes back to the model - bind, search (including a search the server refuses), \
364 add, modify, delete - and the model's answer is executed in turn, bounded at \
365 four follow-ups",
366 )
367 .llm_control("Full control over bind, search, add, modify, delete operations")
368 .e2e_testing(
369 "tests/client/ldap/real_server_test.rs, 19 LLM calls, against OpenLDAP's slapd \
370 (mdb, core/cosine/inetorgperson) seeded with ldapadd and read back with \
371 ldapsearch. The model binds as the rootdn, searches, adds an entry whose \
372 description it built from the search result, modifies a mail and deletes an \
373 entry; ldapsearch must find each effect. A search slapd refuses \
374 (noSuchObject) reaches the model and it creates the missing OU. The follow-up \
375 bound is asserted from slapd's own log: exactly five searches. Not \
376 #[ignore]d; a missing slapd, ldapadd, ldapsearch or schema directory fails \
377 the test rather than skipping it.",
378 )
379 .notes(
380 "ldap3's synchronous LdapConn owns the socket, so every verb runs on \
381 spawn_blocking and NetGet never sees the wire bytes. Simple bind only: no \
382 SASL, no LDAPS or StartTLS. Search results are not paged, and attributes \
383 whose values are not UTF-8 (jpegPhoto, certificates) are left out of them. Bind credentials come \
384 from the model or the operator; no key material or system account is read.",
385 )
386 .build()
387 }
388 fn description(&self) -> &'static str {
389 "LDAP client for directory services operations"
390 }
391 fn example_prompt(&self) -> &'static str {
392 "Connect to LDAP at localhost:389, bind as cn=admin,dc=example,dc=com and search for all users"
393 }
394 fn group_name(&self) -> &'static str {
395 "Directory"
396 }
397 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
398 use crate::llm::actions::StartupExamples;
399 use serde_json::json;
400
401 StartupExamples::new(
402 json!({
404 "type": "open_client",
405 "remote_addr": "localhost:389",
406 "base_stack": "ldap",
407 "instruction": "Bind as cn=admin,dc=example,dc=com and search for all users"
408 }),
409 json!({
411 "type": "open_client",
412 "remote_addr": "localhost:389",
413 "base_stack": "ldap",
414 "event_handlers": [{
415 "event_pattern": "ldap_search_results",
416 "handler": {
417 "type": "script",
418 "language": "python",
419 "code": "<ldap_client_handler>"
420 }
421 }]
422 }),
423 json!({
425 "type": "open_client",
426 "remote_addr": "localhost:389",
427 "base_stack": "ldap",
428 "event_handlers": [
429 {
430 "event_pattern": "ldap_connected",
431 "handler": {
432 "type": "static",
433 "actions": [{
434 "type": "bind",
435 "dn": "cn=admin,dc=example,dc=com",
436 "password": "secret"
437 }]
438 }
439 },
440 {
441 "event_pattern": "ldap_bind_response",
442 "handler": {
443 "type": "static",
444 "actions": [{
445 "type": "search",
446 "base_dn": "dc=example,dc=com",
447 "filter": "(objectClass=person)"
448 }]
449 }
450 },
451 {
452 "event_pattern": "ldap_search_results",
453 "handler": {
454 "type": "static",
455 "actions": [{
456 "type": "disconnect"
457 }]
458 }
459 }
460 ]
461 }),
462 )
463 }
464}
465
466impl Client for LdapClientProtocol {
468 fn connect(
469 &self,
470 ctx: crate::protocol::ConnectContext,
471 ) -> std::pin::Pin<
472 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
473 > {
474 Box::pin(async move {
475 use crate::client::ldap::LdapClient;
476 LdapClient::connect_with_llm_actions(
477 ctx.remote_addr,
478 ctx.llm_client,
479 ctx.state,
480 ctx.status_tx,
481 ctx.client_id,
482 )
483 .await
484 })
485 }
486 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
487 let action_type = action
488 .get("type")
489 .and_then(|v| v.as_str())
490 .context("Missing 'type' field in action")?;
491
492 match action_type {
493 "bind" => {
494 let dn = action
495 .get("dn")
496 .and_then(|v| v.as_str())
497 .context("Missing 'dn' field")?
498 .to_string();
499 let password = action
500 .get("password")
501 .and_then(|v| v.as_str())
502 .context("Missing 'password' field")?
503 .to_string();
504
505 Ok(ClientActionResult::Custom {
506 name: "ldap_bind".to_string(),
507 data: json!({
508 "dn": dn,
509 "password": password,
510 }),
511 })
512 }
513 "search" => {
514 let base_dn = action
515 .get("base_dn")
516 .and_then(|v| v.as_str())
517 .context("Missing 'base_dn' field")?
518 .to_string();
519 let filter = action
520 .get("filter")
521 .and_then(|v| v.as_str())
522 .context("Missing 'filter' field")?
523 .to_string();
524 let attributes = action
525 .get("attributes")
526 .and_then(|v| v.as_array())
527 .map(|arr| {
528 arr.iter()
529 .filter_map(|v| v.as_str().map(|s| s.to_string()))
530 .collect::<Vec<String>>()
531 })
532 .unwrap_or_default();
533 let scope = action
534 .get("scope")
535 .and_then(|v| v.as_str())
536 .unwrap_or("subtree")
537 .to_string();
538
539 Ok(ClientActionResult::Custom {
540 name: "ldap_search".to_string(),
541 data: json!({
542 "base_dn": base_dn,
543 "filter": filter,
544 "attributes": attributes,
545 "scope": scope,
546 }),
547 })
548 }
549 "add" => {
550 let dn = action
551 .get("dn")
552 .and_then(|v| v.as_str())
553 .context("Missing 'dn' field")?
554 .to_string();
555 let attributes = action
556 .get("attributes")
557 .context("Missing 'attributes' field")?
558 .clone();
559
560 Ok(ClientActionResult::Custom {
561 name: "ldap_add".to_string(),
562 data: json!({
563 "dn": dn,
564 "attributes": attributes,
565 }),
566 })
567 }
568 "modify" => {
569 let dn = action
570 .get("dn")
571 .and_then(|v| v.as_str())
572 .context("Missing 'dn' field")?
573 .to_string();
574 let operation = action
575 .get("operation")
576 .and_then(|v| v.as_str())
577 .context("Missing 'operation' field")?
578 .to_string();
579 let attribute = action
580 .get("attribute")
581 .and_then(|v| v.as_str())
582 .context("Missing 'attribute' field")?
583 .to_string();
584 let values = action
585 .get("values")
586 .and_then(|v| v.as_array())
587 .context("Missing 'values' field")?
588 .iter()
589 .filter_map(|v| v.as_str().map(|s| s.to_string()))
590 .collect::<Vec<String>>();
591
592 Ok(ClientActionResult::Custom {
593 name: "ldap_modify".to_string(),
594 data: json!({
595 "dn": dn,
596 "operation": operation,
597 "attribute": attribute,
598 "values": values,
599 }),
600 })
601 }
602 "delete" => {
603 let dn = action
604 .get("dn")
605 .and_then(|v| v.as_str())
606 .context("Missing 'dn' field")?
607 .to_string();
608
609 Ok(ClientActionResult::Custom {
610 name: "ldap_delete".to_string(),
611 data: json!({
612 "dn": dn,
613 }),
614 })
615 }
616 "disconnect" => Ok(ClientActionResult::Disconnect),
617 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
618 _ => Err(anyhow::anyhow!(
619 "Unknown LDAP client action: {}",
620 action_type
621 )),
622 }
623 }
624}