netget/server/zookeeper/mod.rs
1//! ZooKeeper server implementation.
2//!
3//! # Session lifecycle
4//!
5//! A ZooKeeper connection starts with a `ConnectRequest`, which carries **neither an xid nor
6//! an opcode**:
7//!
8//! ```text
9//! [4 len][4 protocolVersion][8 lastZxidSeen][4 timeOut][8 sessionId][4 len][16 passwd][1 readOnly?]
10//! ```
11//!
12//! It is therefore parsed separately from every later frame, and answered with a
13//! `ConnectResponse` (`protocolVersion, timeOut, sessionId, passwd[, readOnly]`). Until that
14//! reply lands, a real client blocks and never issues a request — which is exactly why every
15//! opcode below used to be dead code.
16//!
17//! The handshake is **answered in Rust and deliberately does not call the LLM**. It carries no
18//! content decision (it is timeout negotiation and an opaque session id), and routing it
19//! through the model would mean a model outage or a refusal could not be told apart from a
20//! successful session — the fail-open shape this project treats as its most dangerous pattern.
21//! Pings (opcode 11) and `closeSession` (opcode -11) are answered in Rust for the same reason:
22//! a real ZooKeeper answers them itself, and an idle session must not burn one LLM call per
23//! ping interval to stay alive.
24//!
25//! Everything a *handler* can decide — the reply body for `getData`, `getChildren`, `exists`,
26//! `create`, and the error code for any of them — still goes through `call_llm` and the
27//! protocol's actions.
28//!
29//! # No session state
30//!
31//! The server keeps no session table (see the no-storage rule in the root `CLAUDE.md`). A
32//! client that presents a non-zero `sessionId` is taken at its word and that id is echoed back
33//! along with the password it presented, so a reconnect resumes rather than being reported as
34//! expired. Nothing is ever expired, because nothing is ever tracked.
35//!
36//! See `src/server/zookeeper/CLAUDE.md` for the remaining limitations.
37
38pub mod actions;
39
40use crate::console_debug;
41use crate::llm::action_helper::call_llm;
42use crate::llm::actions::protocol_trait::ActionResult;
43use crate::llm::ollama_client::OllamaClient;
44use crate::protocol::Event;
45use crate::server::connection::ConnectionId;
46use crate::state::app_state::AppState;
47use actions::{ZookeeperProtocol, ZOOKEEPER_REQUEST_EVENT};
48use anyhow::Result;
49use std::net::SocketAddr;
50use std::sync::Arc;
51use tokio::io::{AsyncReadExt, AsyncWriteExt};
52use tokio::net::{TcpListener, TcpStream};
53use tokio::sync::mpsc;
54
55/// How long to wait for the ConnectRequest from a peer that has only connected.
56///
57/// ZooKeeper is client-speaks-first: the ConnectRequest is the first frame on the wire and the
58/// server says nothing before it. Every real client sends it inside its connect path. A peer
59/// that has opened the socket and said nothing has not started a session, so it gets the short
60/// bound — and this is the one an unauthenticated flood lives under.
61const CONNECT_REQUEST_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
62
63/// Floor under the session-derived idle bound (see [`idle_timeout_for`]).
64///
65/// [`MIN_SESSION_TIMEOUT_MS`] is four seconds, and doubling it leaves eight — short enough that
66/// a scheduling hiccup on a loaded test runner could close a healthy session. Thirty seconds
67/// costs nothing against an attacker (who never reaches this bound at all: it applies only
68/// after a session exists) and removes that failure mode.
69const MIN_IDLE_AFTER_CONNECT: std::time::Duration = std::time::Duration::from_secs(30);
70
71/// Concurrent connections this server admits.
72const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
73
74/// A peer over [`MAX_CONNECTIONS`] gets a plain close, which is exactly what a real ZooKeeper
75/// does.
76///
77/// There is no pre-session error frame in the protocol — the first thing the server may write
78/// is a ConnectResponse, and writing one would *admit* the peer rather than refuse it, which is
79/// the fail-open shape this codebase treats as its most dangerous pattern. Real ZooKeeper
80/// hitting `maxClientCnxns` closes the socket without a reply and logs "Too many connections
81/// from …"; this does the same, and the log line carries `decision=fail_closed_connection_cap`.
82const CONNECTION_CAP_REFUSAL: &[u8] = b"";
83
84/// The idle bound for a connection that has a session, derived from that session.
85///
86/// This is the protocol answering the question itself. A real ZooKeeper server expires a
87/// session when it has heard nothing from the client for the *negotiated* session timeout, and
88/// the client pings at a third of that interval precisely so an idle-but-live session keeps
89/// proving it is alive. So the bound is not a number picked here at all — it is the value this
90/// very connection negotiated, doubled, so a client that misses a ping is not punished for it,
91/// and floored at [`MIN_IDLE_AFTER_CONNECT`]. With [`MAX_SESSION_TIMEOUT_MS`] at 40s the
92/// widest this can be is 80 seconds.
93fn idle_timeout_for(session: &ZookeeperSession) -> std::time::Duration {
94 std::time::Duration::from_millis(session.timeout_ms.max(0) as u64 * 2)
95 .max(MIN_IDLE_AFTER_CONNECT)
96}
97use tracing::{debug, error, info, trace, warn};
98
99/// ZooKeeper's `SystemError` (-1): "this did not work", as the protocol defines it.
100///
101/// Used wherever a reply's `error_code` is absent. Code 0 is `Ok`, so defaulting to 0 tells a
102/// client that a create or a setData it never confirmed actually happened.
103const ZK_SYSTEM_ERROR: i64 = -1;
104
105/// ZooKeeper server implementation
106pub struct ZookeeperServer {
107 llm_client: OllamaClient,
108 #[allow(dead_code)]
109 app_state: Arc<AppState>,
110 #[allow(dead_code)]
111 status_tx: mpsc::UnboundedSender<String>,
112 server_id: Option<crate::state::ServerId>,
113}
114
115impl ZookeeperServer {
116 /// Create a new ZooKeeper server
117 pub fn new(
118 llm_client: OllamaClient,
119 app_state: Arc<AppState>,
120 status_tx: mpsc::UnboundedSender<String>,
121 server_id: Option<crate::state::ServerId>,
122 ) -> Self {
123 Self {
124 llm_client,
125 app_state,
126 status_tx,
127 server_id,
128 }
129 }
130
131 /// Spawn ZooKeeper server with LLM integration
132 pub async fn spawn_with_llm_actions(
133 listen_addr: SocketAddr,
134 llm_client: OllamaClient,
135 app_state: Arc<AppState>,
136 status_tx: mpsc::UnboundedSender<String>,
137 _send_first: bool,
138 server_id: crate::state::ServerId,
139 ) -> Result<SocketAddr> {
140 let listener = TcpListener::bind(listen_addr).await?;
141 let actual_addr = listener.local_addr()?;
142
143 info!("ZooKeeper server starting on {}", actual_addr);
144 let _ = status_tx.send(format!(
145 "[INFO] ZooKeeper server listening on {}",
146 actual_addr
147 ));
148 let server = Arc::new(ZookeeperServer::new(
149 llm_client,
150 app_state.clone(),
151 status_tx.clone(),
152 Some(server_id),
153 ));
154
155 let status_tx_clone = status_tx.clone();
156
157 // Spawn the accept loop
158 let task_registrar = app_state.clone();
159 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
160 let accept_handle = tokio::spawn(async move {
161 loop {
162 match crate::server::accept_bounded::accept_bounded(
163 &listener,
164 &limiter,
165 CONNECTION_CAP_REFUSAL,
166 "ZooKeeper",
167 Some(&status_tx),
168 )
169 .await
170 {
171 Ok((stream, addr, permit)) => {
172 console_debug!(status_tx, "ZooKeeper connection from {}", addr);
173
174 let connection_id =
175 ConnectionId::new(app_state.get_next_unified_id().await);
176
177 // Clone server components for the connection handler
178 let server_clone = Arc::clone(&server);
179 let status_tx_conn = status_tx_clone.clone();
180 let app_state_conn = app_state.clone();
181 let server_id_opt = server.server_id;
182
183 // Spawn connection handler
184 // Tracked, not detached: stop_server must abort this task too.
185 let task_owner = app_state.clone();
186 task_owner
187 .spawn_server_task(server_id, async move {
188 // Held for the life of the connection, so the cap counts live
189 // sessions rather than accepts.
190 let _permit = permit;
191 if let Err(e) = Self::handle_connection(
192 stream,
193 server_clone,
194 status_tx_conn,
195 connection_id,
196 app_state_conn,
197 server_id_opt,
198 )
199 .await
200 {
201 error!("ZooKeeper connection error: {}", e);
202 }
203 })
204 .await;
205 }
206 Err(e) => {
207 // A persistent accept error (EMFILE, ENFILE, socket torn down) recurs
208 // immediately, so continuing here spins a hot loop that floods the
209 // unbounded status channel. Give up the listener instead.
210 error!("ZooKeeper accept error, stopping accept loop: {}", e);
211 let _ = status_tx_clone.send(format!(
212 "[ERROR] ZooKeeper accept failed, listener stopped: {}",
213 e
214 ));
215 break;
216 }
217 }
218 }
219 });
220
221 task_registrar
222 .register_server_task(server_id, accept_handle)
223 .await;
224
225 Ok(actual_addr)
226 }
227
228 /// Handle a single ZooKeeper connection
229 async fn handle_connection(
230 stream: TcpStream,
231 server: Arc<ZookeeperServer>,
232 status_tx: mpsc::UnboundedSender<String>,
233 connection_id: ConnectionId,
234 app_state: Arc<AppState>,
235 server_id: Option<crate::state::ServerId>,
236 ) -> Result<()> {
237 let peer_addr = stream.peer_addr().ok();
238 let local_addr = stream.local_addr().ok();
239
240 // Track the connection so the TUI/MCP connection list and stop_server see it.
241 if let (Some(server_id), Some(remote_addr)) = (server_id, peer_addr) {
242 let now = crate::utils::clock::Instant::now();
243 app_state
244 .add_connection_to_server(
245 server_id,
246 crate::state::server::ConnectionState {
247 id: connection_id,
248 remote_addr,
249 local_addr: local_addr.unwrap_or(remote_addr),
250 bytes_sent: 0,
251 bytes_received: 0,
252 packets_sent: 0,
253 packets_received: 0,
254 last_activity: now,
255 status: crate::state::server::ConnectionStatus::Active,
256 status_changed_at: now,
257 protocol_info: crate::state::server::ProtocolConnectionInfo::empty(),
258 },
259 )
260 .await;
261 }
262
263 let (read_half, write_half) = tokio::io::split(stream);
264 let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
265
266 // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
267 // an action into THIS connection. Registered before the first frame is read, so the
268 // operator can reach a connection that is parked on a manual rule. The wire verbs
269 // return `ActionResult::Custom`, so a protocol-owned task (not the generic
270 // `spawn_peer_command_task`) drains the channel and frames the reply exactly as the
271 // LLM path does.
272 if let Some(server_id) = server_id {
273 let peer_rx = crate::server::peer_support::register_peer_channel(
274 &app_state,
275 server_id,
276 connection_id.as_u32(),
277 )
278 .await;
279 Self::spawn_peer_command_task(
280 peer_rx,
281 app_state.clone(),
282 server_id,
283 connection_id,
284 write_half.clone(),
285 status_tx.clone(),
286 )
287 .await;
288 }
289 let _ = status_tx.send("__UPDATE_UI__".to_string());
290
291 let result = Self::run_connection(
292 read_half,
293 &write_half,
294 server,
295 connection_id,
296 app_state.clone(),
297 server_id,
298 )
299 .await;
300
301 // Every exit path - EOF, read error, bad first frame, closeSession, close_connection -
302 // lands here.
303 if let Some(server_id) = server_id {
304 app_state
305 .remove_peer_handle(server_id, connection_id.as_u32())
306 .await;
307 app_state
308 .close_connection_on_server(server_id, connection_id)
309 .await;
310 }
311 let _ = status_tx.send("__UPDATE_UI__".to_string());
312
313 result
314 }
315
316 /// Drain injected actions for one connection.
317 ///
318 /// Mirrors `peer_support::handle_peer_command` (executor, access-log entry, outcome reply,
319 /// close bookkeeping) with one addition: a `zookeeper_response` Custom result is framed
320 /// through [`Self::custom_reply_body`] — the same function the LLM path uses — and written
321 /// to the shared write half. An injected reply that names no `xid` is sent with xid -1,
322 /// the only xid a real ZooKeeper server ever originates (watch notifications): nothing
323 /// else can be correlated to a request the operator cannot see.
324 async fn spawn_peer_command_task<W>(
325 mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
326 app_state: Arc<AppState>,
327 server_id: crate::state::ServerId,
328 connection_id: ConnectionId,
329 write_half: Arc<tokio::sync::Mutex<W>>,
330 status_tx: mpsc::UnboundedSender<String>,
331 ) where
332 W: AsyncWriteExt + Unpin + Send + 'static,
333 {
334 use crate::state::client_handles::ClientSendOutcome;
335 use crate::state::AccessLogOwner;
336
337 let protocol: Arc<ZookeeperProtocol> = Arc::new(ZookeeperProtocol::new());
338 // Tracked, not detached: stop_server must abort this task too.
339 let task_owner = app_state.clone();
340 task_owner
341 .spawn_server_task(server_id, async move {
342 while let Some(command) = command_rx.recv().await {
343 let action = command.action.clone();
344 let outcome = Self::execute_injected_action(
345 protocol.as_ref(),
346 &app_state,
347 server_id,
348 connection_id,
349 &write_half,
350 &action,
351 )
352 .await;
353
354 let outcome_json = match &outcome {
355 Ok(outcome) => {
356 serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null)
357 }
358 Err(e) => serde_json::json!({"error": e.to_string()}),
359 };
360 app_state
361 .record_access_log(
362 AccessLogOwner::Server(server_id.as_u32()),
363 "zookeeper",
364 Some(connection_id.as_u32()),
365 "injected_action",
366 action,
367 vec![outcome_json],
368 )
369 .await;
370
371 match &outcome {
372 Err(e) => warn!(
373 "injected action on ZooKeeper server #{} connection {} failed: {}",
374 server_id.as_u32(),
375 connection_id,
376 e
377 ),
378 Ok(ClientSendOutcome::Disconnected) => {
379 app_state
380 .remove_peer_handle(server_id, connection_id.as_u32())
381 .await;
382 app_state
383 .close_connection_on_server(server_id, connection_id)
384 .await;
385 }
386 Ok(_) => {}
387 }
388 let _ = status_tx.send("__UPDATE_UI__".to_string());
389 let _ = command.reply_tx.send(outcome);
390 }
391 debug!(
392 "peer command task for ZooKeeper server #{} connection {} ended",
393 server_id.as_u32(),
394 connection_id
395 );
396 })
397 .await;
398 }
399
400 async fn execute_injected_action<W>(
401 protocol: &ZookeeperProtocol,
402 app_state: &Arc<AppState>,
403 server_id: crate::state::ServerId,
404 connection_id: ConnectionId,
405 write_half: &Arc<tokio::sync::Mutex<W>>,
406 action: &serde_json::Value,
407 ) -> Result<crate::state::client_handles::ClientSendOutcome>
408 where
409 W: AsyncWriteExt + Unpin,
410 {
411 use crate::state::client_handles::ClientSendOutcome;
412
413 let result = crate::llm::actions::executor::execute_actions(
414 vec![action.clone()],
415 app_state,
416 Some(protocol as &dyn crate::llm::actions::protocol_trait::Server),
417 Some(server_id),
418 None,
419 )
420 .await?;
421
422 let mut bytes_sent = 0usize;
423 let mut closed = false;
424 let mut details: Vec<String> = Vec::new();
425 let mut stack: Vec<ActionResult> = result.protocol_results;
426 stack.reverse();
427 while let Some(item) = stack.pop() {
428 match item {
429 ActionResult::Custom { name, data } if name == "zookeeper_response" => {
430 let body = Self::custom_reply_body(&data, INJECTED_DEFAULT_XID);
431 bytes_sent += Self::write_frame(
432 write_half,
433 app_state,
434 Some(server_id),
435 connection_id,
436 &body,
437 )
438 .await?;
439 }
440 ActionResult::Output(bytes) => {
441 let mut write = write_half.lock().await;
442 write.write_all(&bytes).await?;
443 write.flush().await?;
444 drop(write);
445 Self::count_written(app_state, Some(server_id), connection_id, bytes.len())
446 .await;
447 bytes_sent += bytes.len();
448 }
449 ActionResult::Multiple(items) => {
450 for inner in items.into_iter().rev() {
451 stack.push(inner);
452 }
453 }
454 // Half-close; the peer reads EOF and the reader's own exit path runs.
455 ActionResult::CloseConnection => {
456 let mut write = write_half.lock().await;
457 write.shutdown().await?;
458 closed = true;
459 }
460 ActionResult::WaitForMore | ActionResult::NoAction => {}
461 other => details.push(format!("{other:?}")),
462 }
463 }
464
465 if closed {
466 Ok(ClientSendOutcome::Disconnected)
467 } else if bytes_sent > 0 {
468 Ok(ClientSendOutcome::Sent { bytes_sent })
469 } else if details.is_empty() {
470 Ok(ClientSendOutcome::Executed {
471 detail: "executed (nothing to write)".to_string(),
472 })
473 } else {
474 Ok(ClientSendOutcome::Executed {
475 detail: crate::utils::truncate_for_log(&details.join("; "), 160),
476 })
477 }
478 }
479
480 async fn run_connection<R, W>(
481 mut read_half: R,
482 write_half: &Arc<tokio::sync::Mutex<W>>,
483 server: Arc<ZookeeperServer>,
484 connection_id: ConnectionId,
485 app_state: Arc<AppState>,
486 server_id: Option<crate::state::ServerId>,
487 ) -> Result<()>
488 where
489 R: AsyncReadExt + Unpin,
490 W: AsyncWriteExt + Unpin,
491 {
492 debug!("ZooKeeper connection {} established", connection_id);
493
494 // A session exists only after the ConnectRequest/ConnectResponse exchange. Until then
495 // the next frame is *not* a request header and must not be parsed as one.
496 let mut session: Option<ZookeeperSession> = None;
497
498 loop {
499 // Both reads below are bounded by this deadline, and nothing else in the loop is.
500 // The LLM round-trip that answers the request, and a `manual` rule parking it for a
501 // human (`src/state/intercepts.rs`, 300s by default), happen further down once the
502 // whole frame has arrived — so neither is inside a read deadline and neither can be
503 // cut short by one. What is bounded is a peer that holds the connection while
504 // sending nothing, and one that announces a frame length and then stalls.
505 let read_timeout = match session.as_ref() {
506 Some(session) => idle_timeout_for(session),
507 None => CONNECT_REQUEST_READ_TIMEOUT,
508 };
509
510 // Read ZooKeeper request header (4 bytes length + payload)
511 let mut len_buf = [0u8; 4];
512 let header_read = match tokio::time::timeout(
513 read_timeout,
514 read_half.read_exact(&mut len_buf),
515 )
516 .await
517 {
518 Ok(read) => read,
519 Err(_) => {
520 debug!(
521 "ZooKeeper connection {} sent nothing for {}s; closing idle \
522 connection",
523 connection_id,
524 read_timeout.as_secs()
525 );
526 break;
527 }
528 };
529 match header_read {
530 Ok(_) => {}
531 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
532 debug!("ZooKeeper client disconnected");
533 break;
534 }
535 Err(e) => {
536 return Err(e.into());
537 }
538 }
539
540 // The length prefix is a signed jute int and arrives from the network. Validate it
541 // as i32 *before* widening: `negative as usize` sign-extends to ~1.8e19, and any
542 // check performed after the cast is checking a number the sender never sent.
543 let declared_len = i32::from_be_bytes(len_buf);
544 if declared_len < MIN_REQUEST_BYTES || declared_len > MAX_REQUEST_BYTES {
545 return Err(anyhow::anyhow!(
546 "ZooKeeper request length out of range: {} (expected {}..={})",
547 declared_len,
548 MIN_REQUEST_BYTES,
549 MAX_REQUEST_BYTES
550 ));
551 }
552 let len = declared_len as usize;
553
554 // Read payload
555 let mut payload = vec![0u8; len];
556 match tokio::time::timeout(read_timeout, read_half.read_exact(&mut payload)).await {
557 Ok(read) => read?,
558 Err(_) => {
559 debug!(
560 "ZooKeeper connection {} announced {} octets and stalled for {}s \
561 without delivering them; closing",
562 connection_id,
563 len,
564 read_timeout.as_secs()
565 );
566 break;
567 }
568 };
569 if let Some(server_id) = server_id {
570 app_state
571 .update_connection_stats(
572 server_id,
573 connection_id,
574 Some((4 + len) as u64),
575 None,
576 Some(1),
577 None,
578 )
579 .await;
580 }
581
582 trace!(
583 "ZooKeeper connection {} received {} bytes",
584 connection_id,
585 len
586 );
587
588 // The first frame on a connection is the session handshake, not a request.
589 if session.is_none() {
590 let connect = match Self::parse_connect_request(&payload) {
591 Ok(connect) => connect,
592 Err(e) => {
593 // Refuse rather than guess. Treating an unrecognised opening frame as a
594 // request header is precisely the bug this replaced: it turned every
595 // connect into `operation: "unknown"` and left the client waiting for a
596 // ConnectResponse that never came.
597 warn!(
598 "ZooKeeper connection {}: first frame is not a ConnectRequest ({}); \
599 closing",
600 connection_id, e
601 );
602 return Err(e);
603 }
604 };
605
606 let established = ZookeeperSession::negotiate(&connect, connection_id);
607 Self::write_frame(
608 write_half,
609 &app_state,
610 server_id,
611 connection_id,
612 &established.connect_response(),
613 )
614 .await?;
615
616 info!(
617 "ZooKeeper connection {} session established: id={:#018x} timeout={}ms \
618 (client asked {}ms) read_only={}",
619 connection_id,
620 established.session_id,
621 established.timeout_ms,
622 connect.timeout_ms,
623 established.read_only,
624 );
625 session = Some(established);
626 continue;
627 }
628
629 // Parse request (simplified - just extract op type)
630 let request_info = Self::parse_request(&payload)?;
631
632 // Protocol mechanics the server owns; see the module docs for why these do not
633 // reach the LLM.
634 match request_info.op_code {
635 OP_PING => {
636 trace!("ZooKeeper connection {} ping", connection_id);
637 Self::write_frame(
638 write_half,
639 &app_state,
640 server_id,
641 connection_id,
642 &Self::reply(request_info.xid, 0, 0, &[]),
643 )
644 .await?;
645 continue;
646 }
647 OP_CLOSE_SESSION => {
648 debug!(
649 "ZooKeeper connection {} closed its session on request",
650 connection_id
651 );
652 Self::write_frame(
653 write_half,
654 &app_state,
655 server_id,
656 connection_id,
657 &Self::reply(request_info.xid, 0, 0, &[]),
658 )
659 .await?;
660 return Ok(());
661 }
662 _ => {}
663 }
664
665 // Call LLM with request event. The xid must be in the event data: it is the only
666 // thing a client uses to match a reply to its request, and without it neither the
667 // model nor a script/static handler can echo it back.
668 let event = Event::new(
669 &ZOOKEEPER_REQUEST_EVENT,
670 serde_json::json!({
671 "xid": request_info.xid,
672 "operation": request_info.operation,
673 "op_code": request_info.op_code,
674 "path": request_info.path,
675 }),
676 );
677
678 let server_id = server_id.unwrap_or_else(|| crate::state::ServerId::new(0));
679
680 let protocol = Arc::new(ZookeeperProtocol::new());
681
682 match call_llm(
683 &server.llm_client,
684 &app_state,
685 server_id,
686 Some(connection_id),
687 &event,
688 protocol.as_ref(),
689 )
690 .await
691 {
692 Ok(execution_result) => {
693 // Execute actions
694 let mut answered = false;
695 for result in execution_result.protocol_results {
696 match result {
697 ActionResult::Custom { name, data } if name == "zookeeper_response" => {
698 answered = true;
699 // A handler that omitted the xid gets the request's own xid
700 // rather than 0: xid 0 is never a valid reply to a real
701 // request (negative xids are reserved for pings and watch
702 // notifications) and leaves the client waiting forever.
703 // ZooKeeper error code 0 is `Ok`. Defaulting to it means a
704 // model that produced a reply without deciding an outcome
705 // tells the client the operation succeeded - and for a
706 // create or a setData, the client then proceeds as though a
707 // znode exists. -1 is `SystemError`, the code the protocol
708 // defines for "this did not work", which is the honest
709 // answer for a reply nobody finished describing.
710 let error_code = data
711 .get("error_code")
712 .and_then(|v| v.as_i64())
713 .unwrap_or(ZK_SYSTEM_ERROR);
714 let response = Self::custom_reply_body(&data, request_info.xid);
715 let sent = Self::write_frame(
716 write_half,
717 &app_state,
718 Some(server_id),
719 connection_id,
720 &response,
721 )
722 .await?;
723
724 // Three outcomes must be told apart in the log: the handler
725 // refused (a real decision), the handler said nothing, and the
726 // LLM call failed. This is the first of the three.
727 if error_code != 0 {
728 debug!(
729 "ZooKeeper connection {} decision=model_reject \
730 operation={} xid={} error_code={}",
731 connection_id,
732 request_info.operation,
733 request_info.xid,
734 error_code
735 );
736 }
737 trace!(
738 "ZooKeeper sent {} bytes to connection {}",
739 sent,
740 connection_id,
741 );
742 }
743 ActionResult::CloseConnection => {
744 debug!("ZooKeeper closing connection {}", connection_id);
745 return Ok(());
746 }
747 _ => {}
748 }
749 }
750
751 if !answered {
752 // The handler ran but produced no reply. ZooKeeper is strictly
753 // request/response and correlates by xid alone, so writing nothing
754 // leaves the client blocked until its own session timeout. Answer with
755 // a bare error header — no body, and nothing derived from netget's
756 // internals can appear in one.
757 warn!(
758 "ZooKeeper connection {} decision=fail_closed_no_answer operation={} \
759 xid={}: handler produced no zookeeper_response; replying \
760 ZSYSTEMERROR",
761 connection_id, request_info.operation, request_info.xid
762 );
763 let _ = server.status_tx.send(format!(
764 "[WARN] ZooKeeper {} on connection {} unanswered by handler; \
765 replying ZSYSTEMERROR",
766 request_info.operation, connection_id
767 ));
768 Self::write_frame(
769 write_half,
770 &app_state,
771 Some(server_id),
772 connection_id,
773 &Self::reply(request_info.xid, 0, ZSYSTEMERROR, &[]),
774 )
775 .await?;
776 }
777 }
778 Err(e) => {
779 // Fail closed, but audibly: the peer gets a category, the log gets the
780 // error. ZooKeeper replies carry no free-text field, so the categories are
781 // expressed as distinct error codes — ZOPERATIONTIMEOUT is the transient
782 // one a client retries, ZSYSTEMERROR the one it reports.
783 let failure = crate::utils::WireFailure::classify(&e);
784 let (code, class) = match failure {
785 crate::utils::WireFailure::Overloaded => {
786 (ZOPERATIONTIMEOUT, "fail_closed_overloaded")
787 }
788 crate::utils::WireFailure::Unavailable => {
789 (ZSYSTEMERROR, "fail_closed_llm_error")
790 }
791 };
792 error!(
793 "ZooKeeper connection {} decision={} operation={} xid={} \
794 reply_error_code={}: LLM error: {}",
795 connection_id, class, request_info.operation, request_info.xid, code, e
796 );
797 let _ = server.status_tx.send(format!(
798 "[ERROR] ZooKeeper {} on connection {} failed ({}): {}",
799 request_info.operation,
800 connection_id,
801 class,
802 crate::utils::truncate_for_log(&e.to_string(), 200)
803 ));
804 Self::write_frame(
805 write_half,
806 &app_state,
807 Some(server_id),
808 connection_id,
809 &Self::reply(request_info.xid, 0, code, &[]),
810 )
811 .await?;
812 }
813 }
814 }
815
816 Ok(())
817 }
818
819 /// Turn a `zookeeper_response` Custom result (as produced by `ZookeeperProtocol::
820 /// execute_action`) into a reply body. `default_xid` is used when the action named none.
821 fn custom_reply_body(data: &serde_json::Value, default_xid: i32) -> Vec<u8> {
822 let xid = data
823 .get("xid")
824 .and_then(|v| v.as_i64())
825 .map(|v| v as i32)
826 .unwrap_or(default_xid);
827 let zxid = data.get("zxid").and_then(|v| v.as_i64()).unwrap_or(0);
828 // Same reasoning as the reply path above: absent means failure, not success. The cast
829 // is range-checked too - an out-of-range code narrowed by `as i32` could land on 0.
830 let error_code = data
831 .get("error_code")
832 .and_then(|v| v.as_i64())
833 .and_then(|v| i32::try_from(v).ok())
834 .unwrap_or(ZK_SYSTEM_ERROR as i32);
835 let body = data
836 .get("body_hex")
837 .and_then(|v| v.as_str())
838 .map(hex::decode)
839 .transpose()
840 .unwrap_or_default()
841 .unwrap_or_default();
842 Self::reply(xid, zxid, error_code, &body)
843 }
844
845 /// Write one length-prefixed ZooKeeper frame through the shared write half and count it.
846 /// Returns the number of bytes put on the wire (prefix included). The guard is dropped
847 /// before the stats update so nothing awaits on the state while holding the socket.
848 async fn write_frame<W: AsyncWriteExt + Unpin>(
849 write_half: &Arc<tokio::sync::Mutex<W>>,
850 app_state: &Arc<AppState>,
851 server_id: Option<crate::state::ServerId>,
852 connection_id: ConnectionId,
853 body: &[u8],
854 ) -> Result<usize> {
855 {
856 let mut write = write_half.lock().await;
857 write.write_all(&(body.len() as i32).to_be_bytes()).await?;
858 write.write_all(body).await?;
859 write.flush().await?;
860 }
861 let sent = 4 + body.len();
862 Self::count_written(app_state, server_id, connection_id, sent).await;
863 Ok(sent)
864 }
865
866 async fn count_written(
867 app_state: &Arc<AppState>,
868 server_id: Option<crate::state::ServerId>,
869 connection_id: ConnectionId,
870 bytes: usize,
871 ) {
872 if let Some(server_id) = server_id {
873 app_state
874 .update_connection_stats(
875 server_id,
876 connection_id,
877 None,
878 Some(bytes as u64),
879 None,
880 Some(1),
881 )
882 .await;
883 }
884 }
885
886 /// Build a reply body: `xid (4) + zxid (8) + error code (4) + operation-specific body`.
887 fn reply(xid: i32, zxid: i64, error_code: i32, body: &[u8]) -> Vec<u8> {
888 let mut response = Vec::with_capacity(16 + body.len());
889 response.extend_from_slice(&xid.to_be_bytes());
890 response.extend_from_slice(&zxid.to_be_bytes());
891 response.extend_from_slice(&error_code.to_be_bytes());
892 response.extend_from_slice(body);
893 response
894 }
895
896 /// Parse the opening `ConnectRequest`.
897 ///
898 /// ```text
899 /// protocolVersion i32 | lastZxidSeen i64 | timeOut i32 | sessionId i64
900 /// passwd (i32 length, always 16, followed by that many bytes)
901 /// readOnly u8 (optional — pre-3.4 clients omit it)
902 /// ```
903 ///
904 /// Validation is deliberately strict. The shape is distinctive (`protocolVersion` is 0 and
905 /// the password buffer is exactly 16 bytes), so a frame that fails these checks is not a
906 /// ZooKeeper client and gets a closed connection rather than a reply it cannot use.
907 fn parse_connect_request(payload: &[u8]) -> Result<ZookeeperConnectRequest> {
908 if payload.len() < CONNECT_REQUEST_LEN {
909 return Err(anyhow::anyhow!(
910 "ConnectRequest too short: {} bytes (expected at least {})",
911 payload.len(),
912 CONNECT_REQUEST_LEN
913 ));
914 }
915 if payload.len() > CONNECT_REQUEST_LEN + 1 {
916 return Err(anyhow::anyhow!(
917 "ConnectRequest too long: {} bytes (expected {} or {})",
918 payload.len(),
919 CONNECT_REQUEST_LEN,
920 CONNECT_REQUEST_LEN + 1
921 ));
922 }
923
924 let protocol_version = i32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
925 if protocol_version != 0 {
926 return Err(anyhow::anyhow!(
927 "unsupported ZooKeeper protocol version {}",
928 protocol_version
929 ));
930 }
931
932 let last_zxid_seen = i64::from_be_bytes([
933 payload[4],
934 payload[5],
935 payload[6],
936 payload[7],
937 payload[8],
938 payload[9],
939 payload[10],
940 payload[11],
941 ]);
942 let timeout_ms = i32::from_be_bytes([payload[12], payload[13], payload[14], payload[15]]);
943 let session_id = i64::from_be_bytes([
944 payload[16],
945 payload[17],
946 payload[18],
947 payload[19],
948 payload[20],
949 payload[21],
950 payload[22],
951 payload[23],
952 ]);
953
954 let passwd_len = i32::from_be_bytes([payload[24], payload[25], payload[26], payload[27]]);
955 if passwd_len != SESSION_PASSWD_LEN as i32 {
956 return Err(anyhow::anyhow!(
957 "ConnectRequest password length is {} (expected {})",
958 passwd_len,
959 SESSION_PASSWD_LEN
960 ));
961 }
962 let passwd = payload[28..CONNECT_REQUEST_LEN].to_vec();
963
964 // Pre-3.4 clients stop here; newer ones append a readOnly flag. Which of the two it
965 // was decides whether the reply carries the flag, so it is remembered rather than
966 // defaulted.
967 let read_only = payload.get(CONNECT_REQUEST_LEN).map(|b| *b != 0);
968
969 Ok(ZookeeperConnectRequest {
970 last_zxid_seen,
971 timeout_ms,
972 session_id,
973 passwd,
974 read_only,
975 })
976 }
977
978 /// Parse the ZooKeeper request header.
979 ///
980 /// Only the header (xid, opcode) and the leading path string are read; the rest of the
981 /// Jute-encoded body is not decoded. This is called only *after* the session handshake —
982 /// a `ConnectRequest` has neither an xid nor an opcode and is handled by
983 /// [`Self::parse_connect_request`].
984 fn parse_request(payload: &[u8]) -> Result<ZookeeperRequest> {
985 if payload.len() < 8 {
986 return Ok(ZookeeperRequest {
987 xid: 0,
988 op_code: 0,
989 operation: "unknown".to_string(),
990 path: String::new(),
991 });
992 }
993
994 // Read xid (transaction id)
995 let xid = i32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
996
997 // Read op type
998 let op_type = i32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
999
1000 let operation = match op_type {
1001 OP_CLOSE_SESSION => "closeSession",
1002 1 => "create",
1003 2 => "delete",
1004 3 => "exists",
1005 4 => "getData",
1006 5 => "setData",
1007 6 => "getACL",
1008 7 => "setACL",
1009 8 => "getChildren",
1010 9 => "sync",
1011 11 => "ping",
1012 12 => "getChildren2",
1013 13 => "check",
1014 14 => "multi",
1015 _ => "unknown",
1016 };
1017
1018 // Try to extract the path (a jute ustring: signed length + bytes).
1019 //
1020 // The length is validated as i32 against the bytes actually remaining. Casting first
1021 // was a remote panic: a length of -1 became usize::MAX, `12 + usize::MAX` wrapped to
1022 // 11, `payload.len() >= 11` passed, and `&payload[12..11]` panicked with start > end.
1023 let path = if payload.len() >= 12 {
1024 let declared = i32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]);
1025 let available = payload.len() - 12;
1026 if declared > 0 && (declared as usize) <= available {
1027 String::from_utf8_lossy(&payload[12..12 + declared as usize]).to_string()
1028 } else {
1029 String::new()
1030 }
1031 } else {
1032 String::new()
1033 };
1034
1035 Ok(ZookeeperRequest {
1036 xid,
1037 op_code: op_type,
1038 operation: operation.to_string(),
1039 path,
1040 })
1041 }
1042}
1043
1044/// Smallest payload that can carry a request header (xid + opcode).
1045const MIN_REQUEST_BYTES: i32 = 8;
1046/// Matches ZooKeeper's own `jute.maxbuffer` default of 1 MiB.
1047pub const MAX_REQUEST_BYTES: i32 = 1024 * 1024;
1048
1049/// xid for an injected reply that names none. -1 is the watch-notification xid, the one frame a
1050/// real server originates on its own; any other value would claim to answer a request.
1051const INJECTED_DEFAULT_XID: i32 = -1;
1052
1053/// ZooKeeper API error codes used when netget itself cannot answer.
1054///
1055/// They are the two categories of `crate::utils::WireFailure` mapped onto the codes a real
1056/// ZooKeeper uses, and they are deliberately different: `ZOPERATIONTIMEOUT` reads as transient
1057/// (a client backs off and retries), `ZSYSTEMERROR` as a server-side fault. Both send a
1058/// header-only reply — a ZooKeeper error frame carries no body, which is also why this
1059/// protocol cannot leak an internal error string onto the wire.
1060const ZSYSTEMERROR: i32 = -1;
1061const ZOPERATIONTIMEOUT: i32 = -7;
1062
1063/// Opcodes the server answers itself rather than handing to a handler.
1064const OP_PING: i32 = 11;
1065const OP_CLOSE_SESSION: i32 = -11;
1066
1067/// `ConnectRequest` length without the optional trailing `readOnly` byte.
1068const CONNECT_REQUEST_LEN: usize = 44;
1069/// ZooKeeper session passwords are always 16 bytes.
1070const SESSION_PASSWD_LEN: usize = 16;
1071
1072/// ZooKeeper's own defaults: `minSessionTimeout = 2 * tickTime`,
1073/// `maxSessionTimeout = 20 * tickTime`, with the default `tickTime` of 2000 ms.
1074const MIN_SESSION_TIMEOUT_MS: i32 = 4_000;
1075const MAX_SESSION_TIMEOUT_MS: i32 = 40_000;
1076
1077struct ZookeeperRequest {
1078 xid: i32,
1079 op_code: i32,
1080 operation: String,
1081 path: String,
1082}
1083
1084/// The opening `ConnectRequest`, as sent by the client.
1085struct ZookeeperConnectRequest {
1086 #[allow(dead_code)]
1087 last_zxid_seen: i64,
1088 timeout_ms: i32,
1089 session_id: i64,
1090 passwd: Vec<u8>,
1091 /// `None` when the client omitted the field (pre-3.4 wire format).
1092 read_only: Option<bool>,
1093}
1094
1095/// A negotiated session. Held for the life of the connection; nothing is stored beyond it.
1096struct ZookeeperSession {
1097 session_id: i64,
1098 timeout_ms: i32,
1099 passwd: Vec<u8>,
1100 read_only: bool,
1101 /// Whether the client's `ConnectRequest` carried a `readOnly` field. The reply mirrors it:
1102 /// appending the byte for a client that did not send one is harmless for most clients but
1103 /// is not what a real server does.
1104 echo_read_only: bool,
1105}
1106
1107impl ZookeeperSession {
1108 fn negotiate(req: &ZookeeperConnectRequest, connection_id: ConnectionId) -> Self {
1109 // ZooKeeper clamps the requested timeout into the server's configured range rather
1110 // than rejecting it; a non-positive request gets the minimum.
1111 let timeout_ms = if req.timeout_ms <= 0 {
1112 MIN_SESSION_TIMEOUT_MS
1113 } else {
1114 req.timeout_ms
1115 .clamp(MIN_SESSION_TIMEOUT_MS, MAX_SESSION_TIMEOUT_MS)
1116 };
1117
1118 // A client presenting a session id is resuming. There is no session table to check it
1119 // against (see the module docs), so it is honoured as-is together with its password —
1120 // the alternative, answering with timeout 0, tells the client its session expired,
1121 // which would be a lie about state we never kept.
1122 let (session_id, passwd) = if req.session_id != 0 {
1123 (req.session_id, req.passwd.clone())
1124 } else {
1125 let id = Self::mint_session_id(connection_id);
1126 (id, Self::derive_passwd(id))
1127 };
1128
1129 Self {
1130 session_id,
1131 timeout_ms,
1132 passwd,
1133 // Read-only mode means "serve stale reads while partitioned from the quorum".
1134 // There is no quorum here, so the session is always read-write.
1135 read_only: false,
1136 echo_read_only: req.read_only.is_some(),
1137 }
1138 }
1139
1140 /// A session id that is unique per connection and never zero (zero means "no session").
1141 fn mint_session_id(connection_id: ConnectionId) -> i64 {
1142 let nanos = crate::utils::clock::SystemTime::now()
1143 .duration_since(crate::utils::clock::UNIX_EPOCH)
1144 .map(|d| d.as_nanos() as u64)
1145 .unwrap_or(0);
1146 let id = ((nanos << 16) ^ u64::from(connection_id.as_u32())) & 0x7fff_ffff_ffff_ffff;
1147 if id == 0 {
1148 1
1149 } else {
1150 id as i64
1151 }
1152 }
1153
1154 /// The 16-byte session password. Real ZooKeeper derives it from the session id with a
1155 /// digest so a client cannot forge one; there is no session table here to protect, so this
1156 /// only has to be stable for the session and the right length.
1157 fn derive_passwd(session_id: i64) -> Vec<u8> {
1158 let base = session_id.to_be_bytes();
1159 let mut passwd = Vec::with_capacity(SESSION_PASSWD_LEN);
1160 passwd.extend_from_slice(&base);
1161 passwd.extend(base.iter().map(|b| b ^ 0x5a));
1162 passwd
1163 }
1164
1165 /// Encode the `ConnectResponse` body (the caller adds the length prefix).
1166 fn connect_response(&self) -> Vec<u8> {
1167 let mut body = Vec::with_capacity(4 + 4 + 8 + 4 + SESSION_PASSWD_LEN + 1);
1168 body.extend_from_slice(&0i32.to_be_bytes()); // protocolVersion
1169 body.extend_from_slice(&self.timeout_ms.to_be_bytes());
1170 body.extend_from_slice(&self.session_id.to_be_bytes());
1171 body.extend_from_slice(&(self.passwd.len() as i32).to_be_bytes());
1172 body.extend_from_slice(&self.passwd);
1173 if self.echo_read_only {
1174 body.push(self.read_only as u8);
1175 }
1176 body
1177 }
1178}