Skip to main content

netget/server/mongodb/
mod.rs

1//! MongoDB server implementation with manual OP_MSG parsing
2pub mod actions;
3
4use crate::llm::action_helper::call_llm;
5use crate::llm::actions::protocol_trait::ActionResult;
6use crate::llm::ollama_client::OllamaClient;
7use crate::protocol::Event;
8use crate::server::connection::ConnectionId;
9use crate::state::app_state::AppState;
10use crate::{console_debug, console_error};
11use actions::{MongodbProtocol, MONGODB_COMMAND_EVENT, MONGODB_DISCONNECTED_EVENT};
12use anyhow::{Context, Result};
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::{TcpListener, TcpStream};
17use tokio::sync::{mpsc, Mutex};
18use tracing::{debug, error, info, trace, warn};
19
20use crate::utils::bson_depth::{
21    scan_bson_document, scan_bson_document_with_limit, BsonScan, MAX_BSON_DEPTH,
22};
23use bson::{doc, Bson, Document};
24
25/// Largest MongoDB wire message we will accept.
26///
27/// Matches the server-advertised `maxMessageSizeBytes` (48 MB). The header's `messageLength`
28/// is attacker-controlled, so it has to be range-checked before it is used as an allocation
29/// size - see `read_message_body`.
30pub const MAX_MESSAGE_SIZE: i32 = 48 * 1024 * 1024;
31
32/// MongoDB wire protocol opcode for OP_MSG (MongoDB 3.6+).
33const OP_MSG: i32 = 2013;
34
35/// How long the rest of a message may take to arrive once its 16-byte header has.
36///
37/// [`MAX_MESSAGE_SIZE`] bounds one buffer; this bounds how long a peer may hold it. Without
38/// it, sixteen bytes claiming a 48 MB body pin 48 MB per connection indefinitely.
39const BODY_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
40
41/// How long to wait for a peer's first message header after it has connected.
42///
43/// MongoDB is client-speaks-first: the server says nothing until an OP_MSG arrives, and every
44/// real driver opens with `hello`/`isMaster` inside its own connect path. A peer that has
45/// connected and sent no header at all has started nothing, so it gets the short bound.
46const FIRST_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
47
48/// How long to wait for a *further* message header once one has been answered.
49///
50/// Ten minutes, matching the driver-side idle window the official drivers ship
51/// (`maxIdleTimeMS` is unset by default, but connection pools reap at this order and a
52/// `heartbeatFrequencyMS` of 10 s keeps a monitoring connection far inside it). A pooled
53/// connection between operations is legitimately silent for minutes; forever is not a
54/// legitimate configuration.
55///
56/// **This bound is what makes `[ disconnect this peer ]` take effect.** The dashboard's
57/// disconnect half-closes the socket and marks the row closed, which a peer that is not
58/// reading never notices — so without a deadline on the *header* read this task stayed parked
59/// in `read_exact` until the peer itself closed, holding the connection's tasks and state row
60/// behind an operator action that reported success. [`BODY_READ_TIMEOUT`] did not cover it:
61/// that one arms only once sixteen bytes have already arrived.
62///
63/// Like every read deadline in this tree it is armed lazily, per read: the future is created
64/// at the top of the loop, *after* the previous message's LLM round-trip (or a `manual` rule
65/// parked for a human, 300 s by default) has finished, so no clock runs during that work and a
66/// long park cannot evict a live session. That is the TFTP eviction defect stated in reverse.
67const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
68
69/// Concurrent connections this server admits before it starts refusing.
70///
71/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`].
72/// [`FIRST_HEADER_READ_TIMEOUT`] and [`IDLE_BETWEEN_MESSAGES_TIMEOUT`] bound how long *one*
73/// peer holds a slot; only a cap bounds how many of them there can be at once, and the idle
74/// bound is ten minutes, so without a cap a peer connecting once a second pins six hundred
75/// sockets before the first is even eligible to close.
76///
77/// A MongoDB connection is a pooled *session*, not a request — a driver opens
78/// `maxPoolSize` (100 by default) per client and keeps them — so this server's live-connection
79/// count is its client population times that pool, and the house default sits deliberately
80/// above two such clients. The per-connection memory is bounded separately by
81/// [`MAX_MESSAGE_SIZE`], which is the half of the product that actually describes the cost;
82/// a deployment that wants a tighter total should tighten that rather than this.
83const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
84
85/// A peer over [`MAX_CONNECTIONS`] gets a **plain close**, with nothing written.
86///
87/// MongoDB's wire protocol has a perfectly good way to say "unavailable" — this server already
88/// sends [`MONGODB_TEMPORARILY_UNAVAILABLE`] when the model backend is saturated — and it is
89/// unusable here for the reason `ipp` and `ident` are silent: **every reply is addressed to a
90/// request that a refused peer has not sent.** A wire message's header carries `responseTo`,
91/// which the driver matches against its outstanding `requestID`; nothing has arrived at the
92/// accept, so the only value available is zero.
93///
94/// That is worse than silence rather than merely useless. A driver that receives a reply it
95/// did not ask for does not surface it as an error — it has no request to fail — so an
96/// invented OP_MSG is discarded while the driver goes on waiting for the `hello` it did send,
97/// and the peer we meant to turn away instead blocks until its own `connectTimeoutMS`. An EOF
98/// during the handshake is a case every driver already has: the connection is marked failed
99/// immediately, the pool retries elsewhere, and SDAM records a transient network error rather
100/// than a protocol one.
101///
102/// The refusal is logged with `decision=fail_closed_connection_cap`, and that log line is the
103/// diagnosis.
104const CONNECTION_CAP_REFUSAL: &[u8] = b"";
105
106/// The write half of one connection, shared between the session loop and the dashboard's
107/// peer-command task (`server::peer_support`).
108///
109/// Both may write, so the mutex is what keeps an injected write from landing inside an
110/// OP_MSG the session is emitting. The guard is never held across an LLM call — see
111/// `write_response`.
112type SharedWrite = Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>;
113
114/// MongoDB server implementation
115pub struct MongodbServer {
116    llm_client: OllamaClient,
117    app_state: Arc<AppState>,
118    _status_tx: mpsc::UnboundedSender<String>,
119    server_id: Option<crate::state::ServerId>,
120}
121
122impl MongodbServer {
123    /// Create a new MongoDB server
124    pub fn new(
125        llm_client: OllamaClient,
126        app_state: Arc<AppState>,
127        status_tx: mpsc::UnboundedSender<String>,
128        server_id: Option<crate::state::ServerId>,
129    ) -> Self {
130        Self {
131            llm_client,
132            app_state,
133            _status_tx: status_tx,
134            server_id,
135        }
136    }
137
138    /// Spawn MongoDB server with LLM integration
139    pub async fn spawn_with_llm_actions(
140        listen_addr: SocketAddr,
141        llm_client: OllamaClient,
142        app_state: Arc<AppState>,
143        status_tx: mpsc::UnboundedSender<String>,
144        server_id: crate::state::ServerId,
145    ) -> Result<SocketAddr> {
146        let listener = TcpListener::bind(listen_addr).await?;
147        let actual_addr = listener.local_addr()?;
148
149        info!("MongoDB server starting on {}", actual_addr);
150        let _ = status_tx.send(format!(
151            "[INFO] MongoDB server listening on {}",
152            actual_addr
153        ));
154
155        let server = Arc::new(MongodbServer::new(
156            llm_client,
157            app_state.clone(),
158            status_tx.clone(),
159            Some(server_id),
160        ));
161
162        let status_tx_clone = status_tx.clone();
163        let task_registrar = app_state.clone();
164
165        // Spawn the accept loop
166        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
167        let accept_handle = tokio::spawn(async move {
168            loop {
169                match crate::server::accept_bounded::accept_bounded(
170                    &listener,
171                    &limiter,
172                    CONNECTION_CAP_REFUSAL,
173                    "MONGODB",
174                    Some(&status_tx),
175                )
176                .await
177                {
178                    Ok((stream, addr, permit)) => {
179                        console_debug!(status_tx, "MongoDB connection from {}", addr);
180
181                        let connection_id =
182                            ConnectionId::new(app_state.get_next_unified_id().await);
183                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
184
185                        // Track the connection
186                        if let Some(server_id) = server.server_id {
187                            use crate::state::server::{
188                                ConnectionState as ServerConnectionState, ConnectionStatus,
189                                ProtocolConnectionInfo,
190                            };
191                            let now = crate::utils::clock::Instant::now();
192                            let conn_state = ServerConnectionState {
193                                id: connection_id,
194                                remote_addr: addr,
195                                local_addr: local_addr_conn,
196                                bytes_sent: 0,
197                                bytes_received: 0,
198                                packets_sent: 0,
199                                packets_received: 0,
200                                last_activity: now,
201                                status: ConnectionStatus::Active,
202                                status_changed_at: now,
203                                protocol_info: ProtocolConnectionInfo::empty(),
204                            };
205                            server
206                                .app_state
207                                .add_connection_to_server(server_id, conn_state)
208                                .await;
209                        }
210
211                        let handler = MongodbHandler::new(
212                            connection_id,
213                            server.llm_client.clone(),
214                            server.app_state.clone(),
215                            status_tx.clone(),
216                            server.server_id,
217                            addr,
218                        );
219
220                        // Tracked, not detached: stop_server must abort this task too.
221                        let task_owner = app_state.clone();
222                        task_owner
223                            .spawn_server_task(server_id, async move {
224                                // Released when this task ends, and this task is the whole of
225                                // the connection — `handle_connection` spawns nothing that
226                                // outlives it; the dashboard's peer-command task writes
227                                // through a shared write half rather than owning one — so
228                                // `MAX_CONNECTIONS` caps live connections rather than accepts.
229                                let _permit = permit;
230                                if let Err(e) = handler.handle_connection(stream).await {
231                                    error!("MongoDB connection error: {:?}", e);
232                                }
233                            })
234                            .await;
235                    }
236                    Err(e) => {
237                        console_error!(status_tx, "MongoDB accept error: {}", e);
238                    }
239                }
240            }
241        });
242
243        // Register the accept loop so stop_server can abort it and release the port.
244        task_registrar
245            .register_server_task(server_id, accept_handle)
246            .await;
247
248        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
249        Ok(actual_addr)
250    }
251}
252
253/// MongoDB connection handler
254pub struct MongodbHandler {
255    connection_id: ConnectionId,
256    llm_client: OllamaClient,
257    app_state: Arc<AppState>,
258    #[allow(dead_code)]
259    status_tx: mpsc::UnboundedSender<String>,
260    #[allow(dead_code)]
261    server_id: Option<crate::state::ServerId>,
262    #[allow(dead_code)]
263    remote_addr: SocketAddr,
264    /// MongoDB protocol handler for action execution
265    protocol: Arc<MongodbProtocol>,
266}
267
268impl MongodbHandler {
269    pub fn new(
270        connection_id: ConnectionId,
271        llm_client: OllamaClient,
272        app_state: Arc<AppState>,
273        status_tx: mpsc::UnboundedSender<String>,
274        server_id: Option<crate::state::ServerId>,
275        remote_addr: SocketAddr,
276    ) -> Self {
277        let protocol = Arc::new(MongodbProtocol::new(
278            connection_id,
279            app_state.clone(),
280            status_tx.clone(),
281        ));
282
283        Self {
284            connection_id,
285            llm_client,
286            app_state,
287            status_tx,
288            server_id,
289            remote_addr,
290            protocol,
291        }
292    }
293
294    /// Run the connection, then always mark it closed in `AppState`.
295    ///
296    /// The socket is split here rather than inside the read loop, because the write half has
297    /// two owners: the loop's own replies and the dashboard's peer-command task. Registration
298    /// happens **before the first read** — MongoDB is client-speaks-first, and a `manual` rule
299    /// can park the very first command for minutes, so the operator has to be able to reach
300    /// (or hang up) a connection that has not said anything yet.
301    async fn handle_connection(self, stream: TcpStream) -> Result<()> {
302        let server_id = self.server_id;
303        let connection_id = self.connection_id;
304        let app_state = self.app_state.clone();
305
306        // Owning split, never a clone: the write half outlives this function's stack frame
307        // inside the peer-command task.
308        let (reader, write_half) = tokio::io::split(stream);
309        let write_half: SharedWrite = Arc::new(Mutex::new(write_half));
310
311        if let Some(server_id) = server_id {
312            let peer_rx = crate::server::peer_support::register_peer_channel(
313                &app_state,
314                server_id,
315                connection_id.as_u32(),
316            )
317            .await;
318            crate::server::peer_support::spawn_peer_command_task(
319                peer_rx,
320                self.protocol.clone(),
321                app_state.clone(),
322                server_id,
323                connection_id.as_u32(),
324                write_half.clone(),
325                self.status_tx.clone(),
326            );
327        }
328
329        let outcome = self.run_session(reader, &write_half).await;
330
331        // Every exit path — EOF, a read error, a refused message length, an injected
332        // disconnect — lands here. The handle goes first so the rail stops offering a dead
333        // connection *before* the disconnected event's round-trip, which a `manual` rule can
334        // park for minutes; the shutdown then makes the FIN immediate rather than waiting for
335        // the peer task to drop its clone of the write half.
336        if let Some(server_id) = server_id {
337            app_state
338                .remove_peer_handle(server_id, connection_id.as_u32())
339                .await;
340        }
341        let _ = write_half.lock().await.shutdown().await;
342        if let Some(server_id) = server_id {
343            app_state
344                .close_connection_on_server(server_id, connection_id)
345                .await;
346        }
347
348        // The socket is finished with before the disconnected event goes to the LLM, rather
349        // than being held open for the round-trip. A session that ended in a read error never
350        // raised this event and still does not: the reason strings describe how a *session*
351        // ended, and there is none to report.
352        match outcome {
353            Ok(disconnect_reason) => {
354                let event = Event::new(
355                    &MONGODB_DISCONNECTED_EVENT,
356                    serde_json::json!({"reason": disconnect_reason}),
357                );
358                let server_id = server_id.unwrap_or_else(|| crate::state::ServerId::new(0));
359                let _ = call_llm(
360                    &self.llm_client,
361                    &self.app_state,
362                    server_id,
363                    Some(self.connection_id),
364                    &event,
365                    self.protocol.as_ref(),
366                )
367                .await;
368                Ok(())
369            }
370            Err(e) => Err(e),
371        }
372    }
373
374    /// Write a reply and count it.
375    ///
376    /// Every response goes through here so the rail's `↑` counter and `last_activity` cannot
377    /// drift from what actually left the socket — MongoDB is connection-oriented, so nothing
378    /// else refreshes them. The guard is dropped before the stats update, so nothing awaits
379    /// `AppState` while holding the write half.
380    async fn write_response(&self, write_half: &SharedWrite, bytes: &[u8]) -> Result<()> {
381        {
382            let mut writer = write_half.lock().await;
383            writer.write_all(bytes).await?;
384            writer.flush().await?;
385        }
386        self.record_sent(bytes.len() as u64).await;
387        Ok(())
388    }
389
390    async fn record_received(&self, bytes: u64) {
391        if let Some(server_id) = self.server_id {
392            self.app_state
393                .update_connection_stats(
394                    server_id,
395                    self.connection_id,
396                    Some(bytes),
397                    None,
398                    Some(1),
399                    None,
400                )
401                .await;
402        }
403    }
404
405    async fn record_sent(&self, bytes: u64) {
406        if let Some(server_id) = self.server_id {
407            self.app_state
408                .update_connection_stats(
409                    server_id,
410                    self.connection_id,
411                    None,
412                    Some(bytes),
413                    None,
414                    Some(1),
415                )
416                .await;
417        }
418    }
419
420    /// The read/dispatch/reply loop. Returns the reason the session ended, which the caller
421    /// reports as `mongodb_disconnected` once the socket has been shut down.
422    async fn run_session(
423        &self,
424        mut reader: tokio::io::ReadHalf<TcpStream>,
425        write_half: &SharedWrite,
426    ) -> Result<&'static str> {
427        debug!(
428            "MongoDB handler starting for connection {}",
429            self.connection_id
430        );
431
432        // MongoDB doesn't require handshake - client sends first
433        // The disconnect reason reported to the LLM once the socket is done.
434        let mut disconnect_reason = "client_disconnect";
435        // Whether this session has already answered a message, which decides which of the two
436        // header deadlines applies.
437        let mut answered_one = false;
438
439        loop {
440            // Read MongoDB wire protocol message header (16 bytes)
441            // Format: messageLength (4) + requestID (4) + responseTo (4) + opCode (4)
442            //
443            // Bounded: `BODY_READ_TIMEOUT` used to be the only deadline in this loop, and it
444            // arms only after a header has arrived, so a peer that connected and said nothing
445            // — or one the operator disconnected from the dashboard, which half-closes without
446            // the peer noticing — parked this task in `read_exact` indefinitely.
447            let header_timeout = if answered_one {
448                IDLE_BETWEEN_MESSAGES_TIMEOUT
449            } else {
450                FIRST_HEADER_READ_TIMEOUT
451            };
452            let mut header = [0u8; 16];
453            let header_read =
454                match tokio::time::timeout(header_timeout, reader.read_exact(&mut header)).await {
455                    Ok(read) => read,
456                    Err(_) => {
457                        debug!(
458                            "MongoDB: {} sent no message header for {:?}; closing idle connection",
459                            self.remote_addr, header_timeout
460                        );
461                        let _ = self.status_tx.send(format!(
462                            "[INFO] MongoDB: {} sent nothing for {:?}, closing idle connection",
463                            self.remote_addr, header_timeout
464                        ));
465                        disconnect_reason = "idle_timeout";
466                        break;
467                    }
468                };
469            match header_read {
470                Ok(_) => {}
471                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
472                    debug!("MongoDB client disconnected");
473                    break;
474                }
475                Err(e) => {
476                    return Err(e.into());
477                }
478            }
479
480            let message_length = i32::from_le_bytes([header[0], header[1], header[2], header[3]]);
481            let request_id = i32::from_le_bytes([header[4], header[5], header[6], header[7]]);
482            let _response_to = i32::from_le_bytes([header[8], header[9], header[10], header[11]]);
483            let op_code = i32::from_le_bytes([header[12], header[13], header[14], header[15]]);
484
485            trace!(
486                "MongoDB message: length={}, requestID={}, opCode={}",
487                message_length,
488                request_id,
489                op_code
490            );
491
492            // `messageLength` comes off the wire. A value below 16 used to underflow the
493            // `message_length - 16` subtraction into a ~18 exabyte `vec![0u8; n]`, aborting
494            // the process; a value near i32::MAX allocated gigabytes per connection. Both are
495            // reachable with 16 bytes from an unauthenticated peer.
496            if !(16..=MAX_MESSAGE_SIZE).contains(&message_length) {
497                error!(
498                    "MongoDB: rejecting message with out-of-range length {} from {}",
499                    message_length, self.remote_addr
500                );
501                let _ = self.status_tx.send(format!(
502                    "[ERROR] MongoDB: invalid message length {} from {}, closing connection",
503                    message_length, self.remote_addr
504                ));
505                disconnect_reason = "invalid_message_length";
506                break;
507            }
508
509            // Read the rest of the message body.
510            //
511            // The length check above bounds one message at 48 MB, but nothing bounded how
512            // long the peer could take to deliver it. A client that sends a 48 MB header
513            // and then stops holds that whole buffer for the life of the process, and a
514            // hundred such connections is 4.8 GB with sixteen bytes sent each. The rest of
515            // a message whose header has already arrived is in flight by definition, so a
516            // deadline here refuses a stalled peer without truncating a legitimate one.
517            let body_length = (message_length - 16) as usize;
518            let mut body = vec![0u8; body_length];
519            match tokio::time::timeout(BODY_READ_TIMEOUT, reader.read_exact(&mut body)).await {
520                Ok(r) => {
521                    r?;
522                }
523                Err(_) => {
524                    error!(
525                        "MongoDB: {} did not finish a {}-byte message within {:?}, closing",
526                        self.remote_addr, message_length, BODY_READ_TIMEOUT
527                    );
528                    let _ = self.status_tx.send(format!(
529                        "[ERROR] MongoDB: incomplete message body from {} after {:?}, \
530                         closing connection",
531                        self.remote_addr, BODY_READ_TIMEOUT
532                    ));
533                    disconnect_reason = "incomplete_message_body";
534                    break;
535                }
536            }
537            self.record_received(message_length as u64).await;
538            // A whole message has arrived, so this connection is in use rather than merely
539            // open: subsequent header reads get the long, pooled-connection bound.
540            answered_one = true;
541
542            // Parse command based on opCode. Only OP_MSG is implemented; anything else would
543            // leave the client waiting forever for a reply it can parse, so close instead.
544            if op_code != OP_MSG {
545                error!(
546                    "MongoDB: unsupported opCode {} from {}, closing connection",
547                    op_code, self.remote_addr
548                );
549                let _ = self.status_tx.send(format!(
550                    "[ERROR] MongoDB: unsupported opCode {} (only OP_MSG {} is implemented)",
551                    op_code, OP_MSG
552                ));
553                disconnect_reason = "unsupported_opcode";
554                break;
555            }
556
557            let command_doc = match self.parse_op_msg(&body) {
558                Ok(ParsedOpMsg::Command(doc)) => doc,
559                Ok(ParsedOpMsg::TooDeep { limit }) => {
560                    // The whole message has been read, so the connection is still in step:
561                    // answer this request with MongoDB's own depth error and carry on, as a
562                    // real server does. Nothing from the document reaches the model.
563                    warn!(
564                        "MongoDB connection {} decision=fail_closed_bson_too_deep: command \
565                         document nests deeper than {} levels; refused without decoding",
566                        self.connection_id, limit
567                    );
568                    let _ = self.status_tx.send(format!(
569                        "[WARN] MongoDB connection {} decision=fail_closed_bson_too_deep",
570                        self.connection_id
571                    ));
572                    let doc = mongodb_error_doc(MONGODB_OVERFLOW, BSON_TOO_DEEP_ERRMSG);
573                    let response_bytes = self.encode_op_msg_response(request_id, doc)?;
574                    self.write_response(write_half, &response_bytes).await?;
575                    continue;
576                }
577                Err(e) => {
578                    error!("MongoDB: malformed OP_MSG from {}: {}", self.remote_addr, e);
579                    let _ = self
580                        .status_tx
581                        .send(format!("[ERROR] MongoDB: malformed OP_MSG: {}", e));
582                    disconnect_reason = "malformed_op_msg";
583                    break;
584                }
585            };
586
587            trace!("MongoDB command document: {:?}", command_doc);
588
589            // In the MongoDB command format the *first* key is the command name and its value
590            // is the collection, e.g. `{find: "users", filter: {...}, $db: "testdb"}`. The old
591            // code looked for a literal "collection" field, which no command ever sends, so
592            // the documented `collection` event parameter was always null.
593            let command_name = command_doc
594                .keys()
595                .next()
596                .map(|k| k.as_str())
597                .unwrap_or("unknown")
598                .to_string();
599            let collection = command_doc
600                .get(&command_name)
601                .and_then(|v| v.as_str())
602                .map(|s| s.to_string());
603            let database = command_doc.get_str("$db").unwrap_or("admin").to_string();
604
605            // The driver handshake is protocol business, not a question for the model: it must
606            // carry the wire-version range and size limits, and a driver that does not get them
607            // aborts before it ever sends a real command. This mirrors how the sibling database
608            // protocols handle their handshakes (opensrv-mysql, pgwire's startup handler, and
609            // MSSQL's hand-written PRELOGIN/LOGIN).
610            if command_name.eq_ignore_ascii_case("hello")
611                || command_name.eq_ignore_ascii_case("ismaster")
612            {
613                debug!(
614                    "MongoDB handshake ({}) from {}",
615                    command_name, self.remote_addr
616                );
617                let response_bytes = self.encode_op_msg_response(
618                    request_id,
619                    hello_response(self.connection_id.as_u32()),
620                )?;
621                self.write_response(write_half, &response_bytes).await?;
622                continue;
623            }
624
625            // Call LLM with command event
626            let event_data = serde_json::json!({
627                "command": command_name,
628                "database": database,
629                "collection": collection,
630                "filter": self.bson_to_json(command_doc.get("filter")),
631                "document": self.bson_to_json(command_doc.get("documents").or_else(|| command_doc.get("document"))),
632                // `update` and `delete` carry their statements here — inline, or as the kind-1
633                // document sequences drivers send them as, merged by `parse_op_msg_sections`.
634                "updates": self.bson_to_json(command_doc.get("updates")),
635                "deletes": self.bson_to_json(command_doc.get("deletes")),
636            });
637
638            let event = Event::new(&MONGODB_COMMAND_EVENT, event_data);
639
640            let server_id = self
641                .server_id
642                .unwrap_or_else(|| crate::state::ServerId::new(0));
643            let execution_result = match call_llm(
644                &self.llm_client,
645                &self.app_state,
646                server_id,
647                Some(self.connection_id),
648                &event,
649                self.protocol.as_ref(),
650            )
651            .await
652            {
653                Ok(result) => result,
654                Err(e) => {
655                    // `?` here dropped the connection with nothing written, and a
656                    // MongoDB driver blocks on the reply to every command it sends, so
657                    // the operation hung until the driver's own timeout and was then
658                    // reported as a network fault rather than a server error.
659                    //
660                    // `{ok: 0}` is the only shape a driver reads as a command failure;
661                    // anything with `ok: 1` is a result, and an empty result for `find`
662                    // means "no documents matched" - a statement about the data.
663                    //
664                    // The two categories get distinct codes so a client can back off
665                    // rather than record a permanent fault. The choice is constrained:
666                    // MongoDB's *driver-retryable* codes all describe replica-set
667                    // failover (ShutdownInProgress, PrimarySteppedDown,
668                    // NotWritablePrimary), and claiming one would send the driver
669                    // hunting for a new primary that does not exist. `TemporarilyUnavailable`
670                    // (365) says exactly "saturated, try again" without implying a
671                    // topology change, so it carries the overload case; everything else
672                    // is InternalError (1).
673                    let failure = crate::utils::WireFailure::classify(&e);
674                    let code = match failure {
675                        crate::utils::WireFailure::Overloaded => MONGODB_TEMPORARILY_UNAVAILABLE,
676                        crate::utils::WireFailure::Unavailable => MONGODB_INTERNAL_ERROR,
677                    };
678                    error!(
679                        "MongoDB command '{}' on connection {}: decision=fail_closed_llm_error \
680                         category={:?} code={} error={}",
681                        command_name, self.connection_id, failure, code, e
682                    );
683                    let message = failure.prefixed_text();
684                    let _ = self.status_tx.send(format!(
685                        "[ERROR] MongoDB connection {} decision=fail_closed_llm_error code={}: {}",
686                        self.connection_id, code, message
687                    ));
688                    let doc = mongodb_error_doc(code, message);
689                    let response_bytes = self.encode_op_msg_response(request_id, doc)?;
690                    self.write_response(write_half, &response_bytes).await?;
691                    continue;
692                }
693            };
694
695            // Execute actions from LLM
696            let namespace = format!("{}.{}", database, collection.as_deref().unwrap_or("$cmd"));
697            let mut responded = false;
698            let mut close_requested = false;
699
700            for protocol_result in execution_result.protocol_results {
701                match protocol_result {
702                    ActionResult::Custom { name, data } => {
703                        if name == "mongodb_response" {
704                            // A model answer the encoder cannot use is a *third* case,
705                            // distinct from the backend erroring and from the model
706                            // saying nothing. It used to propagate with `?`, which drops
707                            // the connection mid-command: the driver blocks on a reply
708                            // that never comes and reports a network fault. Answer
709                            // `{ok: 0}` and keep the connection, exactly as the LLM-error
710                            // branch does.
711                            let response_doc = match self.json_to_bson_doc(&data, &namespace) {
712                                Ok(d) => {
713                                    if data.get("type").and_then(|v| v.as_str())
714                                        == Some("error_response")
715                                    {
716                                        debug!(
717                                            "MongoDB command '{}' on connection {}: \
718                                             decision=model_reject",
719                                            command_name, self.connection_id
720                                        );
721                                    }
722                                    d
723                                }
724                                Err(e) => {
725                                    error!(
726                                        "MongoDB command '{}' on connection {}: \
727                                         decision=fail_closed_unusable_answer error={}",
728                                        command_name, self.connection_id, e
729                                    );
730                                    let _ = self.status_tx.send(format!(
731                                        "[ERROR] MongoDB connection {} \
732                                         decision=fail_closed_unusable_answer for command '{}'",
733                                        self.connection_id, command_name
734                                    ));
735                                    mongodb_error_doc(
736                                        MONGODB_INTERNAL_ERROR,
737                                        crate::utils::WireFailure::Unavailable.prefixed_text(),
738                                    )
739                                }
740                            };
741                            let response_bytes =
742                                self.encode_op_msg_response(request_id, response_doc)?;
743                            self.write_response(write_half, &response_bytes).await?;
744                            responded = true;
745                        } else {
746                            warn!(
747                                "MongoDB: no wire encoding for action result '{}', ignoring",
748                                name
749                            );
750                        }
751                    }
752                    ActionResult::CloseConnection => {
753                        debug!("Closing MongoDB connection");
754                        close_requested = true;
755                    }
756                    ActionResult::NoAction => {}
757                    _ => {
758                        debug!("Unhandled action result");
759                    }
760                }
761            }
762
763            if !responded && !close_requested {
764                // MongoDB is strictly request/response: a command with no reply hangs the
765                // driver until its own timeout. Answer with an error instead.
766                warn!(
767                    "MongoDB command '{}' on connection {}: decision=fail_closed_no_answer",
768                    command_name, self.connection_id
769                );
770                let _ = self.status_tx.send(format!(
771                    "[WARN] MongoDB connection {} decision=fail_closed_no_answer for command '{}'",
772                    self.connection_id, command_name
773                ));
774                let doc = mongodb_error_doc(
775                    59,
776                    &format!(
777                        "netget: no response produced for command '{}'",
778                        command_name
779                    ),
780                );
781                let response_bytes = self.encode_op_msg_response(request_id, doc)?;
782                self.write_response(write_half, &response_bytes).await?;
783            }
784
785            if close_requested {
786                disconnect_reason = "close_this_connection";
787                break;
788            }
789        }
790
791        Ok(disconnect_reason)
792    }
793
794    /// Parse OP_MSG body (MongoDB 3.6+ wire protocol)
795    fn parse_op_msg(&self, body: &[u8]) -> Result<ParsedOpMsg> {
796        parse_op_msg_sections(body)
797    }
798
799    /// Encode OP_MSG response
800    fn encode_op_msg_response(&self, request_id: i32, doc: Document) -> Result<Vec<u8>> {
801        let mut body = vec![0u8; 5]; // flagBits (4) + section kind (1)
802        body[4] = 0; // Section kind 0 (body)
803
804        // Serialize BSON document
805        let mut doc_bytes = Vec::new();
806        doc.to_writer(&mut doc_bytes)?;
807        body.extend_from_slice(&doc_bytes);
808
809        // Create header
810        let message_length = (16 + body.len()) as i32;
811        let response_to = request_id;
812        let op_code = 2013i32; // OP_MSG
813
814        let mut message = Vec::new();
815        message.extend_from_slice(&message_length.to_le_bytes());
816        message.extend_from_slice(&0i32.to_le_bytes()); // responseID (0 = server)
817        message.extend_from_slice(&response_to.to_le_bytes());
818        message.extend_from_slice(&op_code.to_le_bytes());
819        message.extend_from_slice(&body);
820
821        Ok(message)
822    }
823
824    /// Convert BSON to JSON
825    fn bson_to_json(&self, bson_opt: Option<&Bson>) -> serde_json::Value {
826        match bson_opt {
827            Some(bson) => bson.clone().into_relaxed_extjson(),
828            None => serde_json::Value::Null,
829        }
830    }
831
832    /// Convert JSON action to BSON document for response
833    fn json_to_bson_doc(&self, json: &serde_json::Value, namespace: &str) -> Result<Document> {
834        let action_type = json
835            .get("type")
836            .and_then(|v| v.as_str())
837            .context("Missing action type")?;
838
839        match action_type {
840            "find_response" => {
841                let documents = json
842                    .get("documents")
843                    .and_then(|v| v.as_array())
844                    .context("Missing documents")?;
845
846                let cursor_docs: Vec<Bson> = documents
847                    .iter()
848                    .filter_map(|d| d.clone().try_into().ok())
849                    .collect();
850
851                Ok(doc! {
852                    "ok": 1,
853                    "cursor": {
854                        "id": 0i64,
855                        // The namespace must name the collection the client actually queried;
856                        // this used to be hardcoded to "test.collection".
857                        "ns": namespace,
858                        "firstBatch": cursor_docs
859                    }
860                })
861            }
862            "insert_response" => {
863                let n = json
864                    .get("inserted_count")
865                    .and_then(|v| v.as_u64())
866                    .unwrap_or(1) as i32;
867                Ok(doc! { "ok": 1, "n": n })
868            }
869            "update_response" => {
870                let matched = json
871                    .get("matched_count")
872                    .and_then(|v| v.as_u64())
873                    .unwrap_or(0) as i32;
874                let modified = json
875                    .get("modified_count")
876                    .and_then(|v| v.as_u64())
877                    .unwrap_or(0) as i32;
878                Ok(doc! { "ok": 1, "n": matched, "nModified": modified })
879            }
880            "delete_response" => {
881                let n = json
882                    .get("deleted_count")
883                    .and_then(|v| v.as_u64())
884                    .unwrap_or(0) as i32;
885                Ok(doc! { "ok": 1, "n": n })
886            }
887            "error_response" => {
888                let code = json.get("code").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
889                let message = json
890                    .get("message")
891                    .and_then(|v| v.as_str())
892                    .unwrap_or("Unknown error");
893                Ok(mongodb_error_doc(code, message))
894            }
895            other => Err(anyhow::anyhow!(
896                "MongoDB: unknown response action type '{}'",
897                other
898            )),
899        }
900    }
901}
902
903/// MongoDB `InternalError`: "an internal server error occurred". Paired with `ok: 0`, which is
904/// what makes a driver raise rather than return a result.
905const MONGODB_INTERNAL_ERROR: i32 = 1;
906
907/// MongoDB `TemporarilyUnavailable` (6.0+): the server is saturated and the same request may
908/// succeed later. Used for [`crate::utils::WireFailure::Overloaded`] so a client backs off
909/// instead of recording a permanent fault. Unlike the driver-retryable codes it does not
910/// assert anything about replica-set topology.
911const MONGODB_TEMPORARILY_UNAVAILABLE: i32 = 365;
912
913/// MongoDB `Overflow`: the code its own BSON validator raises for a document nested past its
914/// depth limit, which is exactly the refusal this server makes.
915const MONGODB_OVERFLOW: i32 = 15;
916
917/// The `errmsg` for a command document refused by [`scan_bson_document`] — MongoDB's own
918/// wording for the same refusal, and a constant: nothing from the peer's bytes is echoed.
919const BSON_TOO_DEEP_ERRMSG: &str = "BSONObj exceeded maximum nested object depth";
920
921/// OP_MSG `flagBits` bit 0: a CRC-32C of the message follows the last section.
922const OP_MSG_CHECKSUM_PRESENT: u32 = 1;
923
924/// OP_MSG `flagBits` bits 0-15 are *required*: a receiver must refuse a message carrying one it
925/// does not understand. Bit 0 is the only one defined, and it is handled.
926const OP_MSG_UNKNOWN_REQUIRED_FLAGS: u32 = 0xFFFE;
927
928/// Decode an OP_MSG body — `flagBits`, then sections — into one command document.
929///
930/// Two section kinds exist (MongoDB wire protocol, "OP_MSG"):
931///
932/// * **kind 0**, exactly one per message: the command's body document.
933/// * **kind 1**, zero or more: a *document sequence* — `int32 size`, a C-string identifier, then
934///   BSON documents filling the rest of `size`. Drivers use it for the arrays that can be large:
935///   `insert`'s `documents`, `update`'s `updates`, `delete`'s `deletes`. The identifier names a
936///   field of the command, and the sequence is that field's value.
937///
938/// So each sequence is merged into the body as the array field it names, and the command the
939/// model sees is the same whether the driver sent its documents inline or as a sequence. A
940/// sequence whose field the body also carries is refused, as the wire protocol requires.
941///
942/// Every document — the body and each sequence member — is walked by
943/// [`scan_bson_document`] before `bson` sees it, exactly as the body alone always was. A
944/// sequence member is scanned two levels shallower, because merging puts it inside an array
945/// inside the command, and the merged document must still be within [`MAX_BSON_DEPTH`] for
946/// everything downstream that walks it recursively. The number of documents is bounded by
947/// `MAX_MESSAGE_SIZE`, which bounds the body this is handed.
948///
949/// A checksum, when present, is stripped and not verified: NetGet does not implement CRC-32C,
950/// and drivers set the bit only when asked to.
951fn parse_op_msg_sections(body: &[u8]) -> Result<ParsedOpMsg> {
952    if body.len() < 5 {
953        return Err(anyhow::anyhow!("OP_MSG body too short"));
954    }
955    let flag_bits = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
956    if flag_bits & OP_MSG_UNKNOWN_REQUIRED_FLAGS != 0 {
957        return Err(anyhow::anyhow!(
958            "OP_MSG sets required flag bits this server does not understand: {:#06x}",
959            flag_bits & OP_MSG_UNKNOWN_REQUIRED_FLAGS
960        ));
961    }
962    let mut sections = &body[4..];
963    if flag_bits & OP_MSG_CHECKSUM_PRESENT != 0 {
964        if sections.len() < 4 {
965            return Err(anyhow::anyhow!(
966                "OP_MSG declares a checksum it does not carry"
967            ));
968        }
969        sections = &sections[..sections.len() - 4];
970    }
971
972    let mut command: Option<Document> = None;
973    let mut sequences: Vec<(String, Vec<Bson>)> = Vec::new();
974
975    while let Some((&kind, rest)) = sections.split_first() {
976        match kind {
977            0 => {
978                if command.is_some() {
979                    return Err(anyhow::anyhow!(
980                        "OP_MSG carries more than one kind-0 section"
981                    ));
982                }
983                // The document's shape is walked iteratively before `bson` sees it: `bson` 3.0
984                // recurses once per nested document with no limit, and a stack overflow aborts
985                // the whole process (`src/utils/bson_depth.rs`). Only a document the scan has
986                // seen end, inside the bytes actually present, is decoded — and only those bytes
987                // are handed over, so `bson` never reserves capacity on a declared length the
988                // message does not back.
989                match scan_bson_document(rest) {
990                    BsonScan::Complete { consumed } => {
991                        command = Some(Document::from_reader(&rest[..consumed])?);
992                        sections = &rest[consumed..];
993                    }
994                    BsonScan::TooDeep { limit } => return Ok(ParsedOpMsg::TooDeep { limit }),
995                    BsonScan::Malformed => {
996                        return Err(anyhow::anyhow!("OP_MSG body is not a BSON document"))
997                    }
998                }
999            }
1000            1 => {
1001                let size_bytes: [u8; 4] = rest
1002                    .get(..4)
1003                    .and_then(|b| b.try_into().ok())
1004                    .context("OP_MSG document sequence truncated before its size")?;
1005                let size = i32::from_le_bytes(size_bytes);
1006                // The size counts itself; a sequence also needs at least an empty identifier.
1007                if size < 5 || size as usize > rest.len() {
1008                    return Err(anyhow::anyhow!(
1009                        "OP_MSG document sequence declares {} bytes with {} present",
1010                        size,
1011                        rest.len()
1012                    ));
1013                }
1014                let sequence = &rest[4..size as usize];
1015                let nul = sequence
1016                    .iter()
1017                    .position(|&b| b == 0)
1018                    .context("OP_MSG document sequence identifier is not terminated")?;
1019                let identifier = std::str::from_utf8(&sequence[..nul])
1020                    .context("OP_MSG document sequence identifier is not UTF-8")?
1021                    .to_string();
1022                let mut documents = Vec::new();
1023                let mut remaining = &sequence[nul + 1..];
1024                while !remaining.is_empty() {
1025                    match scan_bson_document_with_limit(remaining, MAX_BSON_DEPTH - 2) {
1026                        BsonScan::Complete { consumed } => {
1027                            documents.push(Bson::Document(Document::from_reader(
1028                                &remaining[..consumed],
1029                            )?));
1030                            remaining = &remaining[consumed..];
1031                        }
1032                        BsonScan::TooDeep { .. } => {
1033                            return Ok(ParsedOpMsg::TooDeep {
1034                                limit: MAX_BSON_DEPTH,
1035                            })
1036                        }
1037                        BsonScan::Malformed => {
1038                            return Err(anyhow::anyhow!(
1039                                "OP_MSG document sequence '{}' holds something that is not a \
1040                                 BSON document",
1041                                identifier
1042                            ))
1043                        }
1044                    }
1045                }
1046                sequences.push((identifier, documents));
1047                sections = &rest[size as usize..];
1048            }
1049            other => {
1050                return Err(anyhow::anyhow!(
1051                    "Unsupported OP_MSG section kind: {}",
1052                    other
1053                ));
1054            }
1055        }
1056    }
1057
1058    let mut command = command.context("OP_MSG has no kind-0 body section")?;
1059    for (identifier, documents) in sequences {
1060        if command.contains_key(&identifier) {
1061            return Err(anyhow::anyhow!(
1062                "OP_MSG supplies '{}' both in its body and as a document sequence",
1063                identifier
1064            ));
1065        }
1066        command.insert(identifier, Bson::Array(documents));
1067    }
1068    Ok(ParsedOpMsg::Command(command))
1069}
1070
1071/// What an OP_MSG held.
1072enum ParsedOpMsg {
1073    /// A decoded command document.
1074    Command(Document),
1075    /// Nested past [`crate::utils::bson_depth::MAX_BSON_DEPTH`]; never decoded.
1076    TooDeep { limit: usize },
1077}
1078
1079/// Build a MongoDB command-failure document.
1080fn mongodb_error_doc(code: i32, message: &str) -> Document {
1081    doc! { "ok": 0, "code": code, "errmsg": message }
1082}
1083
1084/// The reply to `hello` / `isMaster`.
1085///
1086/// A MongoDB driver refuses to use a server that does not advertise a wire-version range it
1087/// supports, so these fields cannot be left to the model. Wire version 17 is MongoDB 6.0.
1088fn hello_response(connection_id: u32) -> Document {
1089    doc! {
1090        "ok": 1,
1091        "isWritablePrimary": true,
1092        "ismaster": true,
1093        "helloOk": true,
1094        "readOnly": false,
1095        "minWireVersion": 0i32,
1096        "maxWireVersion": 17i32,
1097        "maxBsonObjectSize": 16 * 1024 * 1024i32,
1098        "maxMessageSizeBytes": MAX_MESSAGE_SIZE,
1099        "maxWriteBatchSize": 100_000i32,
1100        "logicalSessionTimeoutMinutes": 30i32,
1101        "connectionId": connection_id as i32,
1102        "localTime": bson::DateTime::now(),
1103    }
1104}