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 = §ions[..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}