Skip to main content

netget/server/ldap/
mod.rs

1//! LDAPv3 server with hand-written ASN.1 BER coding.
2//!
3//! No LDAP crate is used, so this module decodes attacker-controlled BER by hand — the
4//! highest-risk parsing in the file-service protocol group. The invariant every decoder here
5//! keeps: **no index or range is used before it has been checked against the remaining
6//! buffer.** `read_ber_element` is the single entry point, and it validates that an element's
7//! claimed length actually fits before handing back a `value` slice; nothing else slices raw
8//! input. Recursion (search filters) is depth-limited, and message size is capped.
9//!
10//! There is no directory. A bind is granted by the model, a search returns entries the model
11//! names, and add/modify/delete are acknowledged without anything changing here, because there
12//! is nothing here to change. Whether a following search agrees with an earlier add is the
13//! model's memory to keep.
14//!
15//! Not implemented: SASL (the mechanism is reported so it can be refused), StartTLS, LDAPS,
16//! referrals, controls, extended operations, compare, modifyDN, and abandon. Search scope,
17//! filter and requested-attribute list are decoded and handed to the model as text, but never
18//! evaluated here.
19pub mod actions;
20
21use anyhow::{Context, Result};
22use std::net::SocketAddr;
23use std::sync::Arc;
24use tokio::sync::mpsc;
25use tracing::{debug, error, trace, warn};
26
27#[cfg(not(target_arch = "wasm32"))]
28use crate::llm::action_helper::call_llm;
29#[cfg(not(target_arch = "wasm32"))]
30use crate::llm::ollama_client::OllamaClient;
31#[cfg(not(target_arch = "wasm32"))]
32use crate::llm::ActionResult;
33#[cfg(not(target_arch = "wasm32"))]
34use crate::logging::emit::Log;
35#[cfg(not(target_arch = "wasm32"))]
36use crate::protocol::Event;
37#[cfg(not(target_arch = "wasm32"))]
38use crate::server::LdapProtocol;
39#[cfg(not(target_arch = "wasm32"))]
40use crate::state::app_state::AppState;
41#[cfg(not(target_arch = "wasm32"))]
42use actions::{
43    LDAP_ADD_EVENT, LDAP_BIND_EVENT, LDAP_DELETE_EVENT, LDAP_MODIFY_EVENT, LDAP_SEARCH_EVENT,
44    LDAP_UNBIND_EVENT,
45};
46
47/// How long to wait for a peer's first LDAP message after it connects.
48///
49/// LDAP is client-speaks-first: the server says nothing until a BindRequest or an anonymous
50/// SearchRequest arrives, so a peer that has connected and sent nothing has begun no session at
51/// all — which is the state an unauthenticated flood lives in. The deadline wraps the `read()`
52/// and nothing else, so a model round-trip, or a `manual` rule parking a bind for a human, sits
53/// outside it by construction.
54///
55/// **This was 30 seconds and it strands NetGet's own client.** The argument for 30 was made
56/// about a stranger, and it is sound about a stranger — but the peer this server most often has
57/// is not one. `src/client/ldap/mod.rs` opens the socket and then sends **nothing**: it raises
58/// `ldap_client_connected` and waits for the model, or for a person. The dashboard offers
59/// `[ + ldap client ]` under a server's peers and `[ send message ]` beneath it, and gives the
60/// connect event a zero-action static rule — "answered with nothing" — so that client sits at
61/// zero bytes sent for exactly as long as the operator takes to compose a bind or a search. The
62/// client's own code says so, in the comment above its `register_command_channel` call: the
63/// command channel is registered early precisely because "a manual `*` rule can park [the
64/// connected event] for minutes". Thirty seconds is less than a person takes, and the socket
65/// was gone while they were still looking at it.
66///
67/// 300 seconds is the window a `manual` rule gives a human to answer one event
68/// (`src/state/intercepts.rs`), which is the number this product already uses for "how long
69/// someone might take". What it costs is that a stranger holds an unauthenticated socket, a
70/// task and an `AppState` row for 300 seconds rather than 30 — still bounded, and still capped
71/// at [`MAX_CONNECTIONS`], so the total exposure is the same 256 sockets it always was and only
72/// the dwell time changed. A listener genuinely exposed to strangers should set
73/// `first_byte_timeout_secs` back down; that is what the parameter is for.
74#[cfg(not(target_arch = "wasm32"))]
75const FIRST_MESSAGE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
76
77/// How long a peer that has already been answered may send nothing further.
78///
79/// There is no interval to sit above: LDAP defines no keepalive, and OpenLDAP's own
80/// `idletimeout` defaults to **0**, i.e. no bound at all. Fifteen minutes is safe because of a
81/// property of *this* server rather than of LDAP: it implements no control and no extended
82/// operation, so persistent search and syncrepl — the only shapes in which a client legitimately
83/// holds an LDAP connection open in silence, waiting for the server to speak — cannot be
84/// requested here. Every operation is request/response. A bound connection that has issued
85/// nothing for fifteen minutes is a pool entry whose owner has gone.
86///
87/// Deliberately far longer than the first bound, and far longer than the 300-second default a
88/// `manual` rule gives a human to answer: "has said nothing at all" and "has gone quiet
89/// mid-session" are different claims and deserve different answers.
90#[cfg(not(target_arch = "wasm32"))]
91const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
92
93/// Concurrent connections this server admits.
94///
95/// Each admitted connection may buffer a message of up to [`MAX_LDAP_MESSAGE`] (1 MiB) before
96/// anything is authenticated, so the cap is what turns that per-connection bound into a total
97/// one.
98#[cfg(not(target_arch = "wasm32"))]
99const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
100
101/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: LDAP's own
102/// **Notice of Disconnection** (RFC 4511 §4.4.1).
103///
104/// An unsolicited `ExtendedResponse` on messageID 0 carrying OID `1.3.6.1.4.1.1466.20036` and
105/// `resultCode = unavailable (52)`. That is the protocol's designated way for a server to say
106/// "this connection is over and here is why", and `ldapsearch` prints it rather than reporting
107/// an unexplained reset.
108///
109/// `busy (51)` reads closer to the truth of a connection cap and is deliberately **not** used:
110/// §4.4.1 permits only `protocolError`, `strongerAuthRequired` and `unavailable` in a Notice of
111/// Disconnection, and a result code outside that set is a malformed notice.
112///
113/// The bytes, decoded:
114/// `30 18` SEQUENCE(24) · `02 01 00` messageID 0 · `78 13` \[APPLICATION 24\] ExtendedResponse(19)
115/// · `0A 01 34` resultCode ENUMERATED 52 · `04 00` matchedDN "" · `04 00` diagnosticMessage ""
116/// · `8A 0A 2B 06 01 04 01 8B 3A 81 9C 44` responseName \[10\] = 1.3.6.1.4.1.1466.20036
117#[cfg(not(target_arch = "wasm32"))]
118const CONNECTION_CAP_REFUSAL: &[u8] = &[
119    0x30, 0x18, 0x02, 0x01, 0x00, 0x78, 0x13, 0x0A, 0x01, 0x34, 0x04, 0x00, 0x04, 0x00, 0x8A, 0x0A,
120    0x2B, 0x06, 0x01, 0x04, 0x01, 0x8B, 0x3A, 0x81, 0x9C, 0x44,
121];
122
123/// LDAP server that handles directory operations with LLM
124pub struct LdapServer;
125
126#[cfg(not(target_arch = "wasm32"))]
127impl LdapServer {
128    /// Spawn LDAP server with integrated LLM actions
129    pub async fn spawn_with_llm_actions(
130        listen_addr: SocketAddr,
131        llm_client: OllamaClient,
132        app_state: Arc<AppState>,
133        status_tx: mpsc::UnboundedSender<String>,
134        server_id: crate::state::ServerId,
135        first_byte_timeout_secs: Option<u64>,
136        idle_timeout_secs: Option<u64>,
137    ) -> Result<SocketAddr> {
138        // Both bounds are tunable because their right value is a property of who is on the other
139        // end, which only the operator knows. The defaults serve NetGet's own client waiting on
140        // a human; a listener exposed to strangers wants the first one much lower.
141        let first_message_timeout = first_byte_timeout_secs
142            .map(std::time::Duration::from_secs)
143            .unwrap_or(FIRST_MESSAGE_READ_TIMEOUT);
144        let idle_timeout = idle_timeout_secs
145            .map(std::time::Duration::from_secs)
146            .unwrap_or(IDLE_BETWEEN_MESSAGES_TIMEOUT);
147        let listener =
148            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
149        let local_addr = listener.local_addr()?;
150        Log::new(Some(&status_tx)).info(format!(
151            "LDAP server (action-based) listening on {}",
152            local_addr
153        ));
154
155        let protocol = Arc::new(LdapProtocol::new());
156
157        let task_registrar = app_state.clone();
158        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
159        let accept_handle = tokio::spawn(async move {
160            loop {
161                match crate::server::accept_bounded::accept_bounded(
162                    &listener,
163                    &limiter,
164                    CONNECTION_CAP_REFUSAL,
165                    "LDAP",
166                    Some(&status_tx),
167                )
168                .await
169                {
170                    Ok((stream, remote_addr, permit)) => {
171                        let connection_id = crate::server::connection::ConnectionId::new(
172                            app_state.get_next_unified_id().await,
173                        );
174                        Log::new(Some(&status_tx)).info(format!(
175                            "LDAP connection {} from {}",
176                            connection_id, remote_addr
177                        ));
178
179                        // Register the peer in AppState. Without this the dashboard rail
180                        // showed an LDAP server with no peers at all while clients were bound
181                        // to it, `[ message this peer ]` had nothing to act on, and no
182                        // connection-scoped scheduled task could ever be created. LDAP is
183                        // connection-oriented, so it must not declare `.connectionless()` and
184                        // the 10-second idle sweep deliberately does not touch these entries -
185                        // the close below is the only thing that retires one.
186                        {
187                            use crate::state::server::{
188                                ConnectionState as ServerConnectionState, ConnectionStatus,
189                                ProtocolConnectionInfo,
190                            };
191                            let now = crate::utils::clock::Instant::now();
192                            let local_addr = stream.local_addr().unwrap_or(local_addr);
193                            app_state
194                                .add_connection_to_server(
195                                    server_id,
196                                    ServerConnectionState {
197                                        id: connection_id,
198                                        remote_addr,
199                                        local_addr,
200                                        bytes_sent: 0,
201                                        bytes_received: 0,
202                                        packets_sent: 0,
203                                        packets_received: 0,
204                                        last_activity: now,
205                                        status: ConnectionStatus::Active,
206                                        status_changed_at: now,
207                                        protocol_info: ProtocolConnectionInfo::empty(),
208                                    },
209                                )
210                                .await;
211                            let _ = status_tx.send("__UPDATE_UI__".to_string());
212                        }
213
214                        let llm_clone = llm_client.clone();
215                        let state_clone = app_state.clone();
216                        let status_clone = status_tx.clone();
217                        let protocol_clone = protocol.clone();
218
219                        // Tracked, not detached: stop_server must abort this task too.
220                        let task_owner = app_state.clone();
221                        task_owner
222                            .spawn_server_task(server_id, async move {
223                                // Held for the life of the connection: dropping it early
224                                // releases the slot while the peer is still here, which
225                                // silently un-caps the server.
226                                let _permit = permit;
227
228                                let mut session = LdapSession {
229                                    stream,
230                                    connection_id,
231                                    server_id,
232                                    llm_client: llm_clone.clone(),
233                                    app_state: state_clone.clone(),
234                                    status_tx: status_clone.clone(),
235                                    protocol: protocol_clone.clone(),
236                                    authenticated: false,
237                                    bind_dn: None,
238                                    first_message_timeout,
239                                    idle_timeout,
240                                };
241
242                                // Handle LDAP session
243                                if let Err(e) = session.handle().await {
244                                    Log::new(Some(&status_clone))
245                                        .error(format!("LDAP session error: {}", e));
246                                }
247
248                                state_clone
249                                    .close_connection_on_server(server_id, connection_id)
250                                    .await;
251                                let _ = status_clone.send("__UPDATE_UI__".to_string());
252
253                                Log::new(Some(&status_clone))
254                                    .info(format!("LDAP connection {} closed", connection_id));
255                            })
256                            .await;
257                    }
258                    Err(e) => {
259                        Log::new(Some(&status_tx))
260                            .error(format!("Failed to accept LDAP connection: {}", e));
261                        break;
262                    }
263                }
264            }
265        });
266
267        task_registrar
268            .register_server_task(server_id, accept_handle)
269            .await;
270
271        Ok(local_addr)
272    }
273}
274
275#[cfg(not(target_arch = "wasm32"))]
276struct LdapSession {
277    stream: tokio::net::TcpStream,
278    connection_id: crate::server::connection::ConnectionId,
279    server_id: crate::state::ServerId,
280    llm_client: OllamaClient,
281    app_state: Arc<AppState>,
282    status_tx: mpsc::UnboundedSender<String>,
283    protocol: Arc<LdapProtocol>,
284    authenticated: bool,
285    bind_dn: Option<String>,
286    /// Resolved [`FIRST_MESSAGE_READ_TIMEOUT`], or the `first_byte_timeout_secs` override.
287    first_message_timeout: std::time::Duration,
288    /// Resolved [`IDLE_BETWEEN_MESSAGES_TIMEOUT`], or the `idle_timeout_secs` override.
289    idle_timeout: std::time::Duration,
290}
291
292#[cfg(not(target_arch = "wasm32"))]
293impl LdapSession {
294    /// Read LDAP messages off the socket and answer them.
295    ///
296    /// Messages are framed by their BER length rather than by read boundaries. The previous
297    /// implementation treated one `read()` as exactly one message, which is wrong in both
298    /// directions: `ldapsearch` pipelines bind and search into a single segment (the second
299    /// message was silently discarded), and any message larger than the 8 KiB buffer, or split
300    /// across segments by the network, was rejected as malformed.
301    async fn handle(&mut self) -> Result<()> {
302        use tokio::io::{AsyncReadExt, AsyncWriteExt};
303
304        let mut buffer: Vec<u8> = Vec::new();
305        let mut chunk = vec![0u8; READ_CHUNK];
306        // "Has said nothing at all" and "has gone quiet mid-session" are different claims and
307        // get different deadlines. This flips on the first message actually handled, not on the
308        // first byte: a peer dripping a partial BER envelope has still begun no operation.
309        let mut answered_one = false;
310
311        loop {
312            // Drain every complete message already buffered before reading more.
313            loop {
314                let message_len = match ldap_message_len(&buffer) {
315                    Ok(Some(len)) => len,
316                    Ok(None) => break, // need more bytes
317                    Err(e) => {
318                        Log::new(Some(&self.status_tx)).error(format!("LDAP framing error: {}", e));
319                        return Ok(());
320                    }
321                };
322
323                let message: Vec<u8> = buffer.drain(..message_len).collect();
324                trace!("LDAP message {} bytes: {:02x?}", message.len(), message);
325
326                let step = match self.handle_message(&message).await {
327                    Ok(step) => step,
328                    Err(e) => {
329                        Log::new(Some(&self.status_tx)).error(format!("LDAP parse error: {}", e));
330                        return Ok(());
331                    }
332                };
333
334                let (response, close_after) = match step {
335                    SessionStep::Respond(response) => (Some(response), false),
336                    SessionStep::RespondAndClose(response) => (Some(response), true),
337                    SessionStep::Close => (None, true),
338                };
339
340                if let Some(response) = response {
341                    Log::new(Some(&self.status_tx)).trace(format!(
342                        "LDAP sending {} bytes: {:02x?}",
343                        response.len(),
344                        response
345                    ));
346                    self.stream.write_all(&response).await?;
347                    self.stream.flush().await?;
348                    self.record_bytes(0, response.len() as u64).await;
349                }
350
351                answered_one = true;
352
353                if close_after {
354                    return Ok(());
355                }
356            }
357
358            if buffer.len() >= MAX_LDAP_MESSAGE {
359                // Only reachable when a peer sends a long-form length header promising a huge
360                // message; ldap_message_len rejects those, so this is belt and braces.
361                error!("LDAP receive buffer exceeded {} bytes", MAX_LDAP_MESSAGE);
362                return Ok(());
363            }
364
365            // The deadline wraps the read and nothing else. `handle_message` above may sit in an
366            // LLM round-trip, or in a `manual` rule parked for a human at the dashboard, for
367            // minutes; none of that is this peer being silent, and none of it is inside this
368            // timeout.
369            let read_deadline = if answered_one {
370                self.idle_timeout
371            } else {
372                self.first_message_timeout
373            };
374            let n = match tokio::time::timeout(read_deadline, self.stream.read(&mut chunk)).await {
375                Err(_) => {
376                    // No Notice of Disconnection here. The peer is being dropped for silence,
377                    // not refused, and a close is what every LDAP client already understands as
378                    // an idle timeout; the reason lives in the log.
379                    Log::new(Some(&self.status_tx)).info(format!(
380                        "LDAP connection {} sent nothing for {}s; closing \
381                         decision=fail_closed_idle_timeout",
382                        self.connection_id,
383                        read_deadline.as_secs()
384                    ));
385                    break;
386                }
387                Ok(Ok(0)) => break, // Connection closed
388                Ok(Ok(n)) => n,
389                Ok(Err(e)) => {
390                    Log::new(Some(&self.status_tx)).error(format!("LDAP read error: {}", e));
391                    break;
392                }
393            };
394            Log::new(Some(&self.status_tx)).trace(format!("LDAP received {} bytes", n));
395            self.record_bytes(n as u64, 0).await;
396            buffer.extend_from_slice(&chunk[..n]);
397        }
398
399        Ok(())
400    }
401
402    /// Keep the connection's byte counters and `last_activity` current.
403    ///
404    /// The rail's down/up figures and every connection-scoped scheduled-task prompt read
405    /// these, and a connection-oriented server has to maintain them itself: the 10-second
406    /// idle sweep that would otherwise touch `last_activity` runs only over protocols that
407    /// declare `.connectionless()`, which LDAP is not.
408    async fn record_bytes(&self, received: u64, sent: u64) {
409        self.app_state
410            .update_connection_stats(
411                self.server_id,
412                self.connection_id,
413                (received > 0).then_some(received),
414                (sent > 0).then_some(sent),
415                (received > 0).then_some(1),
416                (sent > 0).then_some(1),
417            )
418            .await;
419    }
420
421    /// Decode one complete LDAPMessage and dispatch on its protocolOp.
422    async fn handle_message(&mut self, data: &[u8]) -> Result<SessionStep> {
423        let (msg_id, op_tag, op_value) = decode_ldap_message(data)?;
424        debug!(
425            "LDAP message id={} op=0x{:02x} ({} bytes)",
426            msg_id,
427            op_tag,
428            op_value.len()
429        );
430
431        match op_tag {
432            OP_BIND_REQUEST => self.handle_bind_request(msg_id, op_value).await,
433            OP_SEARCH_REQUEST => self.handle_search_request(msg_id, op_value).await,
434            OP_ADD_REQUEST => self.handle_add_request(msg_id, op_value).await,
435            OP_MODIFY_REQUEST => self.handle_modify_request(msg_id, op_value).await,
436            OP_DELETE_REQUEST => self.handle_delete_request(msg_id, op_value).await,
437            OP_UNBIND_REQUEST => self.handle_unbind_request().await,
438            other => {
439                Log::new(Some(&self.status_tx))
440                    .debug(format!("LDAP unsupported operation: 0x{:02x}", other));
441                // Answer with the response type that matches the request where we know it, so
442                // the client's decoder does not reject the reply outright. This used to always
443                // send a BindResponse, which an ldap3 client answering a search reports as a
444                // protocol violation rather than as the protocolError it is.
445                Ok(SessionStep::Respond(encode_ldap_result(
446                    msg_id,
447                    response_tag_for(other),
448                    RESULT_PROTOCOL_ERROR,
449                    "Operation not supported by this server",
450                )))
451            }
452        }
453    }
454
455    /// Run one LLM round-trip for `event` and turn the result into a session step.
456    ///
457    /// `default` is what the peer gets when the model returns nothing usable - never silence,
458    /// because a client with no response simply hangs until its own timeout.
459    ///
460    /// A model *failure* is answered differently, with `unavailable` (52) or `busy` (51) under
461    /// `response_tag`. The distinction matters: the per-operation defaults describe an outcome
462    /// the directory chose (invalidCredentials for a bind, an empty result set for a search),
463    /// and returning one of those for a backend outage would misreport an outage as a decision.
464    /// `unavailable` is the RFC 4511 code for "this server is not able to answer right now",
465    /// and unlike the search default it is never a success, so a failure can never be mistaken
466    /// for an empty-but-valid answer.
467    async fn respond_via_llm(
468        &mut self,
469        event: crate::protocol::Event,
470        default: Vec<u8>,
471        msg_id: i32,
472        response_tag: u8,
473    ) -> (SessionStep, Decided) {
474        let execution_result = match call_llm(
475            &self.llm_client,
476            &self.app_state,
477            self.server_id,
478            Some(self.connection_id),
479            &event,
480            self.protocol.as_ref(),
481        )
482        .await
483        {
484            Ok(result) => result,
485            Err(e) => {
486                Log::new(Some(&self.status_tx)).warn(format!(
487                    "LDAP LLM call failed on connection {} (msg_id {}): {}",
488                    self.connection_id, msg_id, e
489                ));
490
491                // busy (51) says "retry shortly", unavailable (52) says "I am down".
492                let (code, diagnostic) = if crate::llm::is_overload_error(&e) {
493                    warn!(
494                        "LDAP busy on connection {}: LLM capacity exhausted",
495                        self.connection_id
496                    );
497                    (RESULT_BUSY, "Server busy, retry later")
498                } else {
499                    (RESULT_UNAVAILABLE, "Server unavailable")
500                };
501                // The `decision=` token is the only thing that separates the three
502                // outcomes for an operator, because two of them can reach the wire as the
503                // same result code. Keep them distinct the way `src/server/radius/` does:
504                // conflating "the model denied" with "the model never answered" is the
505                // OAuth2 failure mode, and on a directory server that decides who is who it
506                // is the difference between a policy denial and an unnoticed outage.
507                Log::new(Some(&self.status_tx)).error(format!(
508                    "LDAP msg_id {} on connection {}: decision=fail_closed_llm_error \
509                     result={} ({})",
510                    msg_id, self.connection_id, code, diagnostic
511                ));
512                return (
513                    SessionStep::Respond(encode_ldap_result(
514                        msg_id,
515                        response_tag,
516                        code,
517                        diagnostic,
518                    )),
519                    Decided::ByServer,
520                );
521            }
522        };
523
524        let mut output: Option<Vec<u8>> = None;
525        let mut close = false;
526        for protocol_result in execution_result.protocol_results {
527            match protocol_result {
528                ActionResult::Output(data) => {
529                    if output.is_none() {
530                        output = Some(data);
531                    }
532                }
533                ActionResult::CloseConnection => close = true,
534                _ => {}
535            }
536        }
537
538        match (output, close) {
539            (Some(data), false) => (SessionStep::Respond(data), Decided::ByModel),
540            (Some(data), true) => (SessionStep::RespondAndClose(data), Decided::ByModel),
541            (None, true) => (SessionStep::Close, Decided::ByModel),
542            (None, false) => {
543                // Silence is not consent. Every per-operation default is a refusal
544                // (invalidCredentials for a bind, unwillingToPerform for a write); the one
545                // exception is search, whose default is an empty-but-successful result set,
546                // and an empty result set discloses nothing. Either way this is the
547                // *server's* decision, never the model's, and the token says so.
548                Log::new(Some(&self.status_tx)).error(format!(
549                    "LDAP msg_id {} on connection {}: decision=fail_closed_no_action \
550                     (handler returned no response action; sending the refusal default)",
551                    msg_id, self.connection_id
552                ));
553                (SessionStep::Respond(default), Decided::ByServer)
554            }
555        }
556    }
557
558    async fn handle_bind_request(&mut self, msg_id: i32, data: &[u8]) -> Result<SessionStep> {
559        // BindRequest ::= [APPLICATION 0] SEQUENCE {
560        //     version INTEGER, name LDAPDN, authentication AuthenticationChoice }
561        let version_element = read_ber_element(data).context("bind version")?;
562        let version = ber_integer(version_element.value).context("bind version value")?;
563
564        let after_version = &data[version_element.total_len..];
565        let name_element = read_ber_element(after_version).context("bind name")?;
566        let dn = ber_string(name_element.value);
567
568        let after_name = &after_version[name_element.total_len..];
569        let (auth_type, password) = if after_name.is_empty() {
570            ("none".to_string(), String::new())
571        } else {
572            let auth = read_ber_element(after_name).context("bind authentication")?;
573            match auth.tag {
574                AUTH_SIMPLE => ("simple".to_string(), ber_string(auth.value)),
575                AUTH_SASL => {
576                    // SaslCredentials ::= SEQUENCE { mechanism LDAPString, credentials OPTIONAL }
577                    let mechanism = read_ber_element(auth.value)
578                        .map(|m| ber_string(m.value))
579                        .unwrap_or_default();
580                    (format!("sasl:{}", mechanism), String::new())
581                }
582                other => (format!("unknown:0x{:02x}", other), String::new()),
583            }
584        };
585
586        Log::new(Some(&self.status_tx)).debug(format!(
587            "LDAP Bind request: version={}, dn={}, auth={}, password_len={}",
588            version,
589            dn,
590            auth_type,
591            password.len()
592        ));
593
594        let event = Event::new(
595            &LDAP_BIND_EVENT,
596            serde_json::json!({
597                "message_id": msg_id,
598                "version": version,
599                "dn": dn,
600                "password": password,
601                "auth_type": auth_type,
602            }),
603        );
604
605        // Read the bind outcome from the action the model produced rather than by scanning the
606        // encoded bytes back out of the response, which is what this used to do.
607        let default =
608            encode_bind_response(msg_id, RESULT_INVALID_CREDENTIALS, "Invalid credentials");
609        let (step, decided) = self
610            .respond_via_llm(event, default, msg_id, RESPONSE_BIND)
611            .await;
612
613        // `Decided::ByServer` has already logged its own fail_closed_* token, and its
614        // response is a refusal by construction. Only a response the model itself produced
615        // may be reported as the model's decision - reading success back out of the bytes
616        // alone cannot tell the two apart, which is precisely the conflation that turned an
617        // LLM outage into an approval in OAuth2.
618        if decided == Decided::ByModel {
619            if let SessionStep::Respond(ref response) | SessionStep::RespondAndClose(ref response) =
620                step
621            {
622                if bind_succeeded(response) {
623                    self.authenticated = true;
624                    self.bind_dn = Some(dn.clone());
625                    Log::new(Some(&self.status_tx)).info(format!(
626                        "LDAP bind for {:?} on connection {}: decision=model_accept",
627                        dn, self.connection_id
628                    ));
629                } else {
630                    Log::new(Some(&self.status_tx)).info(format!(
631                        "LDAP bind for {:?} on connection {}: decision=model_reject",
632                        dn, self.connection_id
633                    ));
634                }
635            }
636        }
637
638        Ok(step)
639    }
640
641    async fn handle_search_request(&mut self, msg_id: i32, data: &[u8]) -> Result<SessionStep> {
642        let SearchRequestFields {
643            base_dn,
644            scope,
645            filter,
646            attributes,
647        } = parse_search_request(data)?;
648
649        Log::new(Some(&self.status_tx)).debug(format!(
650            "LDAP Search request: base_dn={}, scope={}, filter={}",
651            base_dn, scope, filter
652        ));
653
654        let event = Event::new(
655            &LDAP_SEARCH_EVENT,
656            serde_json::json!({
657                "message_id": msg_id,
658                "base_dn": base_dn,
659                "authenticated": self.authenticated,
660                "bind_dn": self.bind_dn.as_deref().unwrap_or(""),
661                "scope": scope,
662                "filter": filter,
663                "attributes": attributes,
664            }),
665        );
666
667        // Default is an empty but successful result set: a client that gets nothing back at
668        // all hangs, and a failure code here would look like a directory error rather than an
669        // empty search.
670        let default = encode_search_done(msg_id, RESULT_SUCCESS, "");
671        Ok(self
672            .respond_via_llm(event, default, msg_id, RESPONSE_SEARCH_DONE)
673            .await
674            .0)
675    }
676
677    async fn handle_add_request(&mut self, msg_id: i32, data: &[u8]) -> Result<SessionStep> {
678        // AddRequest ::= [APPLICATION 8] SEQUENCE {
679        //     entry LDAPDN, attributes AttributeList }
680        let entry_element = read_ber_element(data).context("add entry DN")?;
681        let dn = ber_string(entry_element.value);
682
683        let attributes = match read_ber_element(&data[entry_element.total_len..]) {
684            Ok(list) => parse_attribute_list(list.value),
685            Err(_) => serde_json::Map::new(),
686        };
687
688        Log::new(Some(&self.status_tx)).debug(format!(
689            "LDAP Add request: dn={}, {} attributes",
690            dn,
691            attributes.len()
692        ));
693
694        let event = Event::new(
695            &LDAP_ADD_EVENT,
696            serde_json::json!({
697                "message_id": msg_id,
698                "dn": dn,
699                "attributes": attributes,
700                "authenticated": self.authenticated,
701                "bind_dn": self.bind_dn.as_deref().unwrap_or(""),
702            }),
703        );
704
705        let default = encode_ldap_result(
706            msg_id,
707            RESPONSE_ADD,
708            RESULT_UNWILLING_TO_PERFORM,
709            "No response from server policy",
710        );
711        Ok(self
712            .respond_via_llm(event, default, msg_id, RESPONSE_ADD)
713            .await
714            .0)
715    }
716
717    async fn handle_modify_request(&mut self, msg_id: i32, data: &[u8]) -> Result<SessionStep> {
718        // ModifyRequest ::= [APPLICATION 6] SEQUENCE {
719        //     object LDAPDN,
720        //     changes SEQUENCE OF SEQUENCE { operation ENUMERATED, modification PartialAttribute } }
721        let object_element = read_ber_element(data).context("modify object DN")?;
722        let dn = ber_string(object_element.value);
723
724        let mut changes = Vec::new();
725        if let Ok(change_list) = read_ber_element(&data[object_element.total_len..]) {
726            let mut cursor = change_list.value;
727            while let Ok(change) = read_ber_element(cursor) {
728                cursor = &cursor[change.total_len..];
729
730                let Ok(operation_element) = read_ber_element(change.value) else {
731                    continue;
732                };
733                let operation = match ber_integer(operation_element.value).unwrap_or(-1) {
734                    0 => "add",
735                    1 => "delete",
736                    2 => "replace",
737                    3 => "increment",
738                    _ => "unknown",
739                };
740
741                let modification = &change.value[operation_element.total_len..];
742                let (attribute, values) = match read_ber_element(modification) {
743                    Ok(partial) => parse_partial_attribute(partial.value),
744                    Err(_) => (String::new(), Vec::new()),
745                };
746
747                changes.push(serde_json::json!({
748                    "operation": operation,
749                    "attribute": attribute,
750                    "values": values,
751                }));
752            }
753        }
754
755        Log::new(Some(&self.status_tx)).debug(format!(
756            "LDAP Modify request: dn={}, {} changes",
757            dn,
758            changes.len()
759        ));
760
761        let event = Event::new(
762            &LDAP_MODIFY_EVENT,
763            serde_json::json!({
764                "message_id": msg_id,
765                "dn": dn,
766                "changes": changes,
767                "authenticated": self.authenticated,
768                "bind_dn": self.bind_dn.as_deref().unwrap_or(""),
769            }),
770        );
771
772        let default = encode_ldap_result(
773            msg_id,
774            RESPONSE_MODIFY,
775            RESULT_UNWILLING_TO_PERFORM,
776            "No response from server policy",
777        );
778        Ok(self
779            .respond_via_llm(event, default, msg_id, RESPONSE_MODIFY)
780            .await
781            .0)
782    }
783
784    async fn handle_delete_request(&mut self, msg_id: i32, data: &[u8]) -> Result<SessionStep> {
785        // DelRequest ::= [APPLICATION 10] LDAPDN - the DN is the primitive value itself.
786        let dn = ber_string(data);
787
788        Log::new(Some(&self.status_tx)).debug(format!("LDAP Delete request: dn={}", dn));
789
790        let event = Event::new(
791            &LDAP_DELETE_EVENT,
792            serde_json::json!({
793                "message_id": msg_id,
794                "dn": dn,
795                "authenticated": self.authenticated,
796                "bind_dn": self.bind_dn.as_deref().unwrap_or(""),
797            }),
798        );
799
800        let default = encode_ldap_result(
801            msg_id,
802            RESPONSE_DELETE,
803            RESULT_UNWILLING_TO_PERFORM,
804            "No response from server policy",
805        );
806        Ok(self
807            .respond_via_llm(event, default, msg_id, RESPONSE_DELETE)
808            .await
809            .0)
810    }
811
812    async fn handle_unbind_request(&mut self) -> Result<SessionStep> {
813        Log::new(Some(&self.status_tx))
814            .debug(format!("LDAP Unbind request from {}", self.connection_id));
815
816        // Informational. RFC 4511 forbids a response to an unbind, and the event type says so
817        // with .with_no_actions(), so nothing here reads the result - it exists to let script
818        // and static handlers observe the disconnect.
819        //
820        // Raised on a detached task, because awaiting it here delays closing the socket by a
821        // whole model round-trip. Verified with ldapadd against a real model: the add was
822        // answered promptly and the client then sat for fifteen seconds on unbind waiting for
823        // a connection teardown that was blocked on an LLM call whose result is discarded.
824        let event = Event::new(
825            &LDAP_UNBIND_EVENT,
826            serde_json::json!({
827                "bind_dn": self.bind_dn.as_deref().unwrap_or(""),
828            }),
829        );
830
831        let llm_client = self.llm_client.clone();
832        let app_state = self.app_state.clone();
833        let protocol = self.protocol.clone();
834        let server_id = self.server_id;
835        let connection_id = self.connection_id;
836        // Tracked, not detached: stop_server must abort this task too.
837        let task_owner = app_state.clone();
838        task_owner
839            .spawn_server_task(server_id, async move {
840                let _ = call_llm(
841                    &llm_client,
842                    &app_state,
843                    server_id,
844                    Some(connection_id),
845                    &event,
846                    protocol.as_ref(),
847                )
848                .await;
849            })
850            .await;
851
852        Ok(SessionStep::Close)
853    }
854}
855
856/// What the session should do after handling one message.
857#[cfg(not(target_arch = "wasm32"))]
858/// Who chose the response the peer is about to receive.
859///
860/// The wire cannot always carry the distinction - a refusal the model chose and a refusal the
861/// server substituted can be the same LDAP result code - so it has to survive in the type and
862/// reach the log. See the `decision=` tokens in `respond_via_llm`.
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864enum Decided {
865    /// The model produced the response.
866    ByModel,
867    /// The model produced nothing usable, or the LLM call failed; the server refused on its
868    /// own and has already logged a `decision=fail_closed_*` token.
869    ByServer,
870}
871
872enum SessionStep {
873    Respond(Vec<u8>),
874    RespondAndClose(Vec<u8>),
875    Close,
876}
877
878// ============================================================================
879// BER decoding
880//
881// Every function here takes attacker-controlled bytes. The rule is that no index or range is
882// used before it has been checked against the remaining buffer: the previous implementation
883// indexed `data[op_start]` and sliced `data[start..start + len]` using lengths read straight
884// off the wire, so a seven-byte message could panic the connection task - silently, while the
885// server kept reporting Running.
886// ============================================================================
887
888/// Largest LDAP message accepted, in bytes. A long-form length header can promise up to 4 GiB.
889#[cfg(not(target_arch = "wasm32"))]
890pub const MAX_LDAP_MESSAGE: usize = 1 << 20;
891
892/// Socket read size. Unrelated to message size now that messages are reassembled.
893#[cfg(not(target_arch = "wasm32"))]
894const READ_CHUNK: usize = 8192;
895
896/// Maximum search-filter nesting rendered. Filters are recursive and client-supplied, so an
897/// unbounded renderer would overflow the stack on a deliberately deep filter.
898///
899/// `fuzz/fuzz_targets/ldap_filter.rs` carries a depth bomb for this; with the check in
900/// `render_filter` removed, that seed kills the fuzzer with a stack overflow.
901#[cfg(not(target_arch = "wasm32"))]
902pub const MAX_FILTER_DEPTH: usize = 32;
903
904#[cfg(not(target_arch = "wasm32"))]
905const TAG_SEQUENCE: u8 = 0x30;
906#[cfg(not(target_arch = "wasm32"))]
907const TAG_INTEGER: u8 = 0x02;
908
909#[cfg(not(target_arch = "wasm32"))]
910const OP_BIND_REQUEST: u8 = 0x60;
911#[cfg(not(target_arch = "wasm32"))]
912const OP_SEARCH_REQUEST: u8 = 0x63;
913#[cfg(not(target_arch = "wasm32"))]
914const OP_MODIFY_REQUEST: u8 = 0x66;
915#[cfg(not(target_arch = "wasm32"))]
916const OP_ADD_REQUEST: u8 = 0x68;
917#[cfg(not(target_arch = "wasm32"))]
918const OP_DELETE_REQUEST: u8 = 0x4A;
919#[cfg(not(target_arch = "wasm32"))]
920const OP_UNBIND_REQUEST: u8 = 0x42;
921
922#[cfg(not(target_arch = "wasm32"))]
923const RESPONSE_BIND: u8 = 0x61;
924#[cfg(not(target_arch = "wasm32"))]
925const RESPONSE_SEARCH_DONE: u8 = 0x65;
926#[cfg(not(target_arch = "wasm32"))]
927const RESPONSE_MODIFY: u8 = 0x67;
928#[cfg(not(target_arch = "wasm32"))]
929const RESPONSE_ADD: u8 = 0x69;
930#[cfg(not(target_arch = "wasm32"))]
931const RESPONSE_DELETE: u8 = 0x6B;
932#[cfg(not(target_arch = "wasm32"))]
933const RESPONSE_EXTENDED: u8 = 0x78;
934
935#[cfg(not(target_arch = "wasm32"))]
936const AUTH_SIMPLE: u8 = 0x80;
937#[cfg(not(target_arch = "wasm32"))]
938const AUTH_SASL: u8 = 0xA3;
939
940#[cfg(not(target_arch = "wasm32"))]
941const RESULT_SUCCESS: u8 = 0;
942#[cfg(not(target_arch = "wasm32"))]
943const RESULT_PROTOCOL_ERROR: u8 = 2;
944#[cfg(not(target_arch = "wasm32"))]
945const RESULT_INVALID_CREDENTIALS: u8 = 49;
946#[cfg(not(target_arch = "wasm32"))]
947const RESULT_BUSY: u8 = 51;
948#[cfg(not(target_arch = "wasm32"))]
949const RESULT_UNAVAILABLE: u8 = 52;
950#[cfg(not(target_arch = "wasm32"))]
951const RESULT_UNWILLING_TO_PERFORM: u8 = 53;
952
953/// The response type matching a request type, for error replies.
954#[cfg(not(target_arch = "wasm32"))]
955fn response_tag_for(request_tag: u8) -> u8 {
956    match request_tag {
957        OP_BIND_REQUEST => RESPONSE_BIND,
958        OP_SEARCH_REQUEST => RESPONSE_SEARCH_DONE,
959        OP_MODIFY_REQUEST => RESPONSE_MODIFY,
960        OP_ADD_REQUEST => RESPONSE_ADD,
961        OP_DELETE_REQUEST => RESPONSE_DELETE,
962        _ => RESPONSE_EXTENDED,
963    }
964}
965
966/// One decoded BER tag-length-value.
967#[cfg(not(target_arch = "wasm32"))]
968struct BerElement<'a> {
969    tag: u8,
970    /// The content octets, already bounds-checked against the input.
971    value: &'a [u8],
972    /// Tag + length + content, i.e. how far to advance to reach the next element.
973    total_len: usize,
974}
975
976/// Decode the element at the start of `data`, verifying it fits.
977#[cfg(not(target_arch = "wasm32"))]
978fn read_ber_element(data: &[u8]) -> Result<BerElement<'_>> {
979    if data.is_empty() {
980        anyhow::bail!("Truncated BER element: no tag");
981    }
982
983    let (content_len, len_bytes) = parse_ber_length(&data[1..])?;
984    let header_len = 1 + len_bytes;
985    let total_len = header_len
986        .checked_add(content_len)
987        .context("BER length overflow")?;
988
989    if total_len > data.len() {
990        anyhow::bail!(
991            "Truncated BER element: claims {} content bytes, {} available",
992            content_len,
993            data.len().saturating_sub(header_len)
994        );
995    }
996
997    Ok(BerElement {
998        tag: data[0],
999        value: &data[header_len..total_len],
1000        total_len,
1001    })
1002}
1003
1004/// Decode a BER length. Returns (length, bytes consumed by the length field).
1005#[cfg(not(target_arch = "wasm32"))]
1006fn parse_ber_length(data: &[u8]) -> Result<(usize, usize)> {
1007    if data.is_empty() {
1008        anyhow::bail!("Empty data for BER length");
1009    }
1010
1011    let first_byte = data[0];
1012
1013    if first_byte & 0x80 == 0 {
1014        // Short form: length is in the first byte
1015        Ok((first_byte as usize, 1))
1016    } else {
1017        // Long form: the low 7 bits say how many bytes encode the length
1018        let num_len_bytes = (first_byte & 0x7F) as usize;
1019        if num_len_bytes == 0 || num_len_bytes > 4 {
1020            anyhow::bail!(
1021                "Invalid BER length encoding ({} length bytes)",
1022                num_len_bytes
1023            );
1024        }
1025
1026        if data.len() < 1 + num_len_bytes {
1027            anyhow::bail!("Insufficient data for BER length");
1028        }
1029
1030        let mut length = 0usize;
1031        for byte in &data[1..1 + num_len_bytes] {
1032            length = (length << 8) | *byte as usize;
1033        }
1034
1035        Ok((length, 1 + num_len_bytes))
1036    }
1037}
1038
1039/// Decode INTEGER/ENUMERATED content octets.
1040#[cfg(not(target_arch = "wasm32"))]
1041fn ber_integer(value: &[u8]) -> Result<i64> {
1042    if value.is_empty() {
1043        anyhow::bail!("Empty BER INTEGER");
1044    }
1045    if value.len() > 8 {
1046        anyhow::bail!("BER INTEGER too wide ({} bytes)", value.len());
1047    }
1048
1049    // Sign-extend from the first byte, as BER integers are two's complement.
1050    let mut result = if value[0] & 0x80 != 0 { -1i64 } else { 0i64 };
1051    for byte in value {
1052        result = (result << 8) | *byte as i64;
1053    }
1054    Ok(result)
1055}
1056
1057/// Decode OCTET STRING content octets as text.
1058#[cfg(not(target_arch = "wasm32"))]
1059fn ber_string(value: &[u8]) -> String {
1060    String::from_utf8_lossy(value).to_string()
1061}
1062
1063/// Length of the complete LDAPMessage at the front of `buffer`, if it has all arrived.
1064///
1065/// `Ok(None)` means "need more bytes"; `Err` means the stream is not LDAP and the connection
1066/// should be dropped.
1067#[cfg(not(target_arch = "wasm32"))]
1068pub fn ldap_message_len(buffer: &[u8]) -> Result<Option<usize>> {
1069    if buffer.is_empty() {
1070        return Ok(None);
1071    }
1072    if buffer[0] != TAG_SEQUENCE {
1073        anyhow::bail!(
1074            "Invalid LDAP message: expected SEQUENCE, got tag 0x{:02x}",
1075            buffer[0]
1076        );
1077    }
1078    if buffer.len() < 2 {
1079        return Ok(None);
1080    }
1081
1082    let first = buffer[1];
1083    let (content_len, len_bytes) = if first & 0x80 == 0 {
1084        (first as usize, 1)
1085    } else {
1086        let n = (first & 0x7F) as usize;
1087        if n == 0 || n > 4 {
1088            anyhow::bail!("Invalid BER length encoding ({} length bytes)", n);
1089        }
1090        if buffer.len() < 2 + n {
1091            return Ok(None);
1092        }
1093        let mut value = 0usize;
1094        for byte in &buffer[2..2 + n] {
1095            value = (value << 8) | *byte as usize;
1096        }
1097        (value, 1 + n)
1098    };
1099
1100    let total = 1 + len_bytes + content_len;
1101    if total > MAX_LDAP_MESSAGE {
1102        anyhow::bail!(
1103            "LDAP message of {} bytes exceeds the {} byte cap",
1104            total,
1105            MAX_LDAP_MESSAGE
1106        );
1107    }
1108    if buffer.len() < total {
1109        return Ok(None);
1110    }
1111    Ok(Some(total))
1112}
1113
1114/// Decode `AttributeList ::= SEQUENCE OF SEQUENCE { type, vals SET OF value }`.
1115#[cfg(not(target_arch = "wasm32"))]
1116fn parse_attribute_list(mut data: &[u8]) -> serde_json::Map<String, serde_json::Value> {
1117    let mut attributes = serde_json::Map::new();
1118    while let Ok(attribute) = read_ber_element(data) {
1119        data = &data[attribute.total_len..];
1120        let (name, values) = parse_partial_attribute(attribute.value);
1121        if !name.is_empty() {
1122            attributes.insert(name, serde_json::Value::Array(values));
1123        }
1124    }
1125    attributes
1126}
1127
1128/// Decode the inside of `PartialAttribute ::= SEQUENCE { type, vals SET OF value }`.
1129#[cfg(not(target_arch = "wasm32"))]
1130fn parse_partial_attribute(data: &[u8]) -> (String, Vec<serde_json::Value>) {
1131    let Ok(type_element) = read_ber_element(data) else {
1132        return (String::new(), Vec::new());
1133    };
1134    let name = ber_string(type_element.value);
1135
1136    let mut values = Vec::new();
1137    if let Ok(set) = read_ber_element(&data[type_element.total_len..]) {
1138        let mut cursor = set.value;
1139        while let Ok(value_element) = read_ber_element(cursor) {
1140            cursor = &cursor[value_element.total_len..];
1141            values.push(serde_json::Value::String(ber_string(value_element.value)));
1142        }
1143    }
1144
1145    (name, values)
1146}
1147
1148/// Split an LDAPMessage into `(messageID, protocolOp tag, protocolOp content)`.
1149///
1150/// Pure and public so `fuzz/fuzz_targets/ldap_filter.rs` drives the same decoder the session
1151/// does; nothing here allocates or recurses.
1152#[cfg(not(target_arch = "wasm32"))]
1153pub fn decode_ldap_message(data: &[u8]) -> Result<(i32, u8, &[u8])> {
1154    let envelope = read_ber_element(data).context("LDAPMessage envelope")?;
1155    if envelope.tag != TAG_SEQUENCE {
1156        anyhow::bail!(
1157            "Invalid LDAP message: expected SEQUENCE, got tag 0x{:02x}",
1158            envelope.tag
1159        );
1160    }
1161    let body = envelope.value;
1162
1163    let id_element = read_ber_element(body).context("messageID")?;
1164    if id_element.tag != TAG_INTEGER {
1165        anyhow::bail!(
1166            "Invalid LDAP message: expected messageID INTEGER, got tag 0x{:02x}",
1167            id_element.tag
1168        );
1169    }
1170    let msg_id = ber_integer(id_element.value).context("messageID value")? as i32;
1171
1172    let op = read_ber_element(&body[id_element.total_len..]).context("protocolOp")?;
1173    Ok((msg_id, op.tag, op.value))
1174}
1175
1176/// What a SearchRequest tells the model: the fields of `ldap_search`'s event data.
1177#[cfg(not(target_arch = "wasm32"))]
1178#[derive(Debug, Clone, PartialEq)]
1179pub struct SearchRequestFields {
1180    pub base_dn: String,
1181    /// `base`, `one` or `sub`.
1182    pub scope: String,
1183    /// RFC 4515 text, rendered to at most `MAX_FILTER_DEPTH` levels.
1184    pub filter: String,
1185    pub attributes: Vec<serde_json::Value>,
1186}
1187
1188/// Decode a SearchRequest's content octets.
1189///
1190/// ```text
1191/// SearchRequest ::= [APPLICATION 3] SEQUENCE {
1192///     baseObject LDAPDN, scope ENUMERATED, derefAliases ENUMERATED,
1193///     sizeLimit INTEGER, timeLimit INTEGER, typesOnly BOOLEAN,
1194///     filter Filter, attributes AttributeSelection }
1195/// ```
1196///
1197/// The filter is the only recursive part of any LDAP request, and it is client-supplied and
1198/// pre-authentication (a search needs no bind). This is the entry point the fuzz target drives.
1199#[cfg(not(target_arch = "wasm32"))]
1200pub fn parse_search_request(data: &[u8]) -> Result<SearchRequestFields> {
1201    let base_element = read_ber_element(data).context("search baseObject")?;
1202    let base_dn = ber_string(base_element.value);
1203
1204    let mut rest = &data[base_element.total_len..];
1205    let mut scope = "sub".to_string();
1206    if let Ok(scope_element) = read_ber_element(rest) {
1207        scope = match ber_integer(scope_element.value).unwrap_or(2) {
1208            0 => "base",
1209            1 => "one",
1210            _ => "sub",
1211        }
1212        .to_string();
1213        rest = &rest[scope_element.total_len..];
1214    }
1215
1216    // derefAliases, sizeLimit, timeLimit, typesOnly: read past them without interpreting.
1217    for _ in 0..4 {
1218        match read_ber_element(rest) {
1219            Ok(element) => rest = &rest[element.total_len..],
1220            Err(_) => break,
1221        }
1222    }
1223
1224    let filter = match read_ber_element(rest) {
1225        Ok(filter_element) => {
1226            let text = render_filter(&filter_element, 0);
1227            rest = &rest[filter_element.total_len..];
1228            text
1229        }
1230        Err(_) => "(objectClass=*)".to_string(),
1231    };
1232
1233    let attributes = match read_ber_element(rest) {
1234        Ok(list) => {
1235            let mut names = Vec::new();
1236            let mut cursor = list.value;
1237            while let Ok(element) = read_ber_element(cursor) {
1238                names.push(serde_json::Value::String(ber_string(element.value)));
1239                cursor = &cursor[element.total_len..];
1240            }
1241            names
1242        }
1243        Err(_) => Vec::new(),
1244    };
1245
1246    Ok(SearchRequestFields {
1247        base_dn,
1248        scope,
1249        filter,
1250        attributes,
1251    })
1252}
1253
1254/// Render a search filter back into RFC 4515 text for the event.
1255///
1256/// Depth-limited: `depth` counts nesting and the renderer stops at MAX_FILTER_DEPTH rather
1257/// than recursing as deep as a client asks.
1258#[cfg(not(target_arch = "wasm32"))]
1259fn render_filter(element: &BerElement<'_>, depth: usize) -> String {
1260    if depth >= MAX_FILTER_DEPTH {
1261        return "(...)".to_string();
1262    }
1263
1264    let render_children = |data: &[u8], prefix: &str| -> String {
1265        let mut out = String::from("(");
1266        out.push_str(prefix);
1267        let mut cursor = data;
1268        while let Ok(child) = read_ber_element(cursor) {
1269            cursor = &cursor[child.total_len..];
1270            out.push_str(&render_filter(&child, depth + 1));
1271        }
1272        out.push(')');
1273        out
1274    };
1275
1276    let render_comparison = |data: &[u8], operator: &str| -> String {
1277        let Ok(attribute) = read_ber_element(data) else {
1278            return "(?)".to_string();
1279        };
1280        let value = read_ber_element(&data[attribute.total_len..])
1281            .map(|v| ber_string(v.value))
1282            .unwrap_or_default();
1283        format!("({}{}{})", ber_string(attribute.value), operator, value)
1284    };
1285
1286    match element.tag {
1287        0xA0 => render_children(element.value, "&"),
1288        0xA1 => render_children(element.value, "|"),
1289        0xA2 => render_children(element.value, "!"),
1290        0xA3 => render_comparison(element.value, "="),
1291        0xA5 => render_comparison(element.value, ">="),
1292        0xA6 => render_comparison(element.value, "<="),
1293        0xA8 => render_comparison(element.value, "~="),
1294        0xA4 => {
1295            // substrings: SEQUENCE { type, SEQUENCE OF CHOICE { initial, any, final } }
1296            let Ok(attribute) = read_ber_element(element.value) else {
1297                return "(?)".to_string();
1298            };
1299            let mut pattern = String::new();
1300            if let Ok(parts) = read_ber_element(&element.value[attribute.total_len..]) {
1301                let mut cursor = parts.value;
1302                let mut leading_star = true;
1303                while let Ok(part) = read_ber_element(cursor) {
1304                    cursor = &cursor[part.total_len..];
1305                    match part.tag {
1306                        0x80 => {
1307                            pattern.push_str(&ber_string(part.value));
1308                            leading_star = false;
1309                        }
1310                        _ => {
1311                            if leading_star {
1312                                pattern.push('*');
1313                                leading_star = false;
1314                            }
1315                            pattern.push('*');
1316                            pattern.push_str(&ber_string(part.value));
1317                        }
1318                    }
1319                }
1320            }
1321            format!("({}={}*)", ber_string(attribute.value), pattern)
1322        }
1323        0x87 => format!("({}=*)", ber_string(element.value)),
1324        other => format!("(filter-0x{:02x}=*)", other),
1325    }
1326}
1327
1328/// True when an encoded BindResponse carries resultCode success.
1329///
1330/// Reads the message structurally instead of scanning for a 0x61 byte, which the old
1331/// implementation did and which any DN or diagnostic message containing that byte could fool.
1332#[cfg(not(target_arch = "wasm32"))]
1333fn bind_succeeded(response: &[u8]) -> bool {
1334    let Ok(envelope) = read_ber_element(response) else {
1335        return false;
1336    };
1337    let Ok(message_id) = read_ber_element(envelope.value) else {
1338        return false;
1339    };
1340    let Ok(op) = read_ber_element(&envelope.value[message_id.total_len..]) else {
1341        return false;
1342    };
1343    if op.tag != RESPONSE_BIND {
1344        return false;
1345    }
1346    read_ber_element(op.value)
1347        .ok()
1348        .and_then(|result_code| ber_integer(result_code.value).ok())
1349        .map(|code| code == RESULT_SUCCESS as i64)
1350        .unwrap_or(false)
1351}
1352
1353#[cfg(not(target_arch = "wasm32"))]
1354fn encode_ber_length(length: usize) -> Vec<u8> {
1355    if length < 128 {
1356        vec![length as u8]
1357    } else if length < 256 {
1358        vec![0x81, length as u8]
1359    } else if length < 65536 {
1360        vec![0x82, (length >> 8) as u8, length as u8]
1361    } else {
1362        vec![
1363            0x83,
1364            (length >> 16) as u8,
1365            (length >> 8) as u8,
1366            length as u8,
1367        ]
1368    }
1369}
1370
1371#[cfg(not(target_arch = "wasm32"))]
1372fn encode_ber_integer(value: i32) -> Vec<u8> {
1373    let mut result = vec![0x02]; // INTEGER tag
1374
1375    if value >= 0 && value < 128 {
1376        result.push(0x01); // length
1377        result.push(value as u8);
1378    } else {
1379        result.push(0x04); // length (4 bytes)
1380        result.extend_from_slice(&value.to_be_bytes());
1381    }
1382
1383    result
1384}
1385
1386#[cfg(not(target_arch = "wasm32"))]
1387fn encode_bind_response(msg_id: i32, result_code: u8, diagnostic_message: &str) -> Vec<u8> {
1388    // BindResponse ::= [APPLICATION 1] SEQUENCE {
1389    //     resultCode ENUMERATED,
1390    //     matchedDN LDAPDN,
1391    //     diagnosticMessage LDAPString,
1392    //     ... }
1393
1394    let mut bind_resp = Vec::new();
1395
1396    // resultCode (ENUMERATED - same encoding as INTEGER)
1397    bind_resp.push(0x0A); // ENUMERATED tag
1398    bind_resp.push(0x01); // length
1399    bind_resp.push(result_code);
1400
1401    // matchedDN (OCTET STRING)
1402    bind_resp.push(0x04); // OCTET STRING tag
1403    bind_resp.push(0x00); // length (empty)
1404
1405    // diagnosticMessage (OCTET STRING)
1406    bind_resp.push(0x04); // OCTET STRING tag
1407    let diag_bytes = diagnostic_message.as_bytes();
1408    bind_resp.extend_from_slice(&encode_ber_length(diag_bytes.len()));
1409    bind_resp.extend_from_slice(diag_bytes);
1410
1411    // Wrap in BindResponse APPLICATION tag [1]
1412    let mut bind_msg = vec![0x61]; // APPLICATION 1
1413    bind_msg.extend_from_slice(&encode_ber_length(bind_resp.len()));
1414    bind_msg.extend_from_slice(&bind_resp);
1415
1416    // Create LDAPMessage SEQUENCE
1417    encode_ldap_message(msg_id, bind_msg)
1418}
1419
1420#[cfg(not(target_arch = "wasm32"))]
1421fn encode_search_done(msg_id: i32, result_code: u8, diagnostic_message: &str) -> Vec<u8> {
1422    // SearchResultDone ::= [APPLICATION 5] LDAPResult
1423
1424    let mut result = Vec::new();
1425
1426    // resultCode (ENUMERATED)
1427    result.push(0x0A);
1428    result.push(0x01);
1429    result.push(result_code);
1430
1431    // matchedDN (empty)
1432    result.push(0x04);
1433    result.push(0x00);
1434
1435    // diagnosticMessage
1436    result.push(0x04);
1437    let diag_bytes = diagnostic_message.as_bytes();
1438    result.extend_from_slice(&encode_ber_length(diag_bytes.len()));
1439    result.extend_from_slice(diag_bytes);
1440
1441    // Wrap in SearchResultDone APPLICATION tag [5]
1442    let mut search_msg = vec![0x65]; // APPLICATION 5
1443    search_msg.extend_from_slice(&encode_ber_length(result.len()));
1444    search_msg.extend_from_slice(&result);
1445
1446    encode_ldap_message(msg_id, search_msg)
1447}
1448
1449/// Encode any LDAPResult-shaped response under the given APPLICATION tag.
1450///
1451/// BindResponse [1] = 0x61, SearchResultDone [5] = 0x65, ModifyResponse [7] = 0x67,
1452/// AddResponse [9] = 0x69, DelResponse [11] = 0x6B, ExtendedResponse [24] = 0x78. All share
1453/// the LDAPResult prefix (resultCode, matchedDN, diagnosticMessage), which is why one encoder
1454/// serves them all.
1455#[cfg(not(target_arch = "wasm32"))]
1456fn encode_ldap_result(
1457    msg_id: i32,
1458    response_tag: u8,
1459    result_code: u8,
1460    diagnostic_message: &str,
1461) -> Vec<u8> {
1462    let mut result = Vec::new();
1463
1464    // resultCode (ENUMERATED)
1465    result.push(0x0A);
1466    result.push(0x01);
1467    result.push(result_code);
1468
1469    // matchedDN (empty OCTET STRING)
1470    result.push(0x04);
1471    result.push(0x00);
1472
1473    // diagnosticMessage (OCTET STRING)
1474    result.push(0x04);
1475    let diag_bytes = diagnostic_message.as_bytes();
1476    result.extend_from_slice(&encode_ber_length(diag_bytes.len()));
1477    result.extend_from_slice(diag_bytes);
1478
1479    let mut message = vec![response_tag];
1480    message.extend_from_slice(&encode_ber_length(result.len()));
1481    message.extend_from_slice(&result);
1482
1483    encode_ldap_message(msg_id, message)
1484}
1485
1486#[cfg(not(target_arch = "wasm32"))]
1487fn encode_ldap_message(msg_id: i32, protocol_op: Vec<u8>) -> Vec<u8> {
1488    // LDAPMessage ::= SEQUENCE {
1489    //     messageID INTEGER,
1490    //     protocolOp CHOICE { ... }
1491    // }
1492
1493    let mut content = Vec::new();
1494    content.extend_from_slice(&encode_ber_integer(msg_id));
1495    content.extend_from_slice(&protocol_op);
1496
1497    let mut message = vec![0x30]; // SEQUENCE tag
1498    message.extend_from_slice(&encode_ber_length(content.len()));
1499    message.extend_from_slice(&content);
1500
1501    message
1502}
1503
1504#[cfg(target_arch = "wasm32")]
1505impl LdapServer {
1506    pub async fn spawn_with_llm_actions(
1507        _listen_addr: SocketAddr,
1508        _llm_client: crate::llm::ollama_client::OllamaClient,
1509        _app_state: Arc<crate::state::app_state::AppState>,
1510        _status_tx: mpsc::UnboundedSender<String>,
1511        _server_id: crate::state::ServerId,
1512        _first_byte_timeout_secs: Option<u64>,
1513        _idle_timeout_secs: Option<u64>,
1514    ) -> Result<SocketAddr> {
1515        anyhow::bail!("LDAP feature not enabled")
1516    }
1517}