Skip to main content

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}