Skip to main content

netget/server/zabbix/
mod.rs

1//! Zabbix trapper — the protocol `zabbix_sender` speaks to a Zabbix server on port 10051.
2//!
3//! One request per connection, as the Zabbix server does it: the sender connects, writes one
4//! `ZBXD` packet carrying `{"request":"sender data","data":[…]}`, reads one response and the
5//! server closes. The model decides how many of the reported values it accepts; NetGet renders
6//! the response.
7//!
8//! Four properties worth knowing before changing the loop:
9//!
10//! 1. **The declared length is judged before anything is allocated for it.** The header is read
11//!    whole (13 or 21 bytes) and [`wire::parse_header`] refuses a length past
12//!    [`wire::MAX_DATA_BYTES`] (1 MiB) before a single body byte is read. Compressed packets are
13//!    refused outright (see `wire.rs`).
14//! 2. **The model writes two numbers.** `send_zabbix_result` is rendered by
15//!    [`wire::render_result`]; the loop reads the counts back and requires
16//!    `processed + failed` to equal the request's own value count before writing, with the real
17//!    `seconds spent` filled in.
18//! 3. **Failure is told as failure in the one form `zabbix_sender` acts on.** A backend
19//!    failure, silence, or counts that do not add up are answered `processed: 0; failed: N;
20//!    total: N` — `zabbix_sender` exits 2. A `{"response":"failed"}` would be the obvious
21//!    choice and is the wrong one: zabbix_sender 7.4 prints a warning and exits **0** on it
22//!    (measured), so a script checking `$?` would believe its values were stored.
23//! 4. **The deadlines wrap the reads, not the answer.** A request parked for a human under a
24//!    `manual` rule is not closed by either.
25pub mod actions;
26pub mod wire;
27
28use crate::llm::action_helper::call_llm;
29use crate::llm::actions::protocol_trait::ActionResult;
30use crate::llm::ollama_client::OllamaClient;
31use crate::logging::emit::Log;
32use crate::protocol::Event;
33use crate::server::connection::ConnectionId;
34use crate::state::app_state::AppState;
35use anyhow::Result;
36use std::net::SocketAddr;
37use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
40use tokio::net::TcpListener;
41use tokio::sync::{mpsc, Mutex};
42
43pub use wire::MAX_DATA_BYTES;
44
45/// How long a new connection may send nothing. `zabbix_sender` writes its request the moment
46/// it connects (the Zabbix server's own `Timeout` defaults to 3 s); 30 s leaves room for a slow
47/// network and for a hand-driven NetGet TCP client, which can raise it further through
48/// `first_byte_timeout_secs`.
49const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(30);
50
51/// How long the server waits for more of a request that has started arriving.
52const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
53
54/// Concurrent connections admitted before new ones are refused — the house default.
55const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
56
57/// Fixed `info` texts for the requests NetGet refuses itself.
58const INFO_TOO_LARGE: &str = "message is too large";
59const INFO_COMPRESSED: &str = "compressed data is not supported";
60const INFO_BAD_FLAGS: &str = "unsupported protocol flags";
61const INFO_UNSUPPORTED: &str = "unsupported request";
62
63pub struct ZabbixServer;
64
65impl ZabbixServer {
66    pub async fn spawn_with_llm_actions(
67        listen_addr: SocketAddr,
68        llm_client: OllamaClient,
69        app_state: Arc<AppState>,
70        status_tx: mpsc::UnboundedSender<String>,
71        server_id: crate::state::ServerId,
72        first_byte_timeout_secs: Option<u64>,
73        idle_timeout_secs: Option<u64>,
74    ) -> Result<SocketAddr> {
75        let first_timeout = first_byte_timeout_secs
76            .map(Duration::from_secs)
77            .unwrap_or(FIRST_BYTE_TIMEOUT);
78        let idle_timeout = idle_timeout_secs
79            .map(Duration::from_secs)
80            .unwrap_or(IDLE_TIMEOUT);
81        let listener = TcpListener::bind(listen_addr).await?;
82        let local_addr = listener.local_addr()?;
83
84        Log::new(Some(&status_tx)).info(format!("Zabbix trapper listening on {}", local_addr));
85
86        let protocol = Arc::new(actions::ZabbixProtocol::new());
87        let task_registrar = app_state.clone();
88        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
89        let accept_handle = tokio::spawn(async move {
90            loop {
91                // A peer over the cap gets no bytes: the response to a request it has not
92                // sent yet would be read as the answer to it. zabbix_sender reports the
93                // closed connection as a failed send, which is the truth.
94                match crate::server::accept_bounded::accept_bounded(
95                    &listener,
96                    &limiter,
97                    b"",
98                    "Zabbix",
99                    Some(&status_tx),
100                )
101                .await
102                {
103                    Ok((socket, peer_addr, permit)) => {
104                        let connection_id =
105                            ConnectionId::new(app_state.get_next_unified_id().await);
106                        use crate::state::server::{
107                            ConnectionState as ServerConnectionState, ConnectionStatus,
108                            ProtocolConnectionInfo,
109                        };
110                        let now = crate::utils::clock::Instant::now();
111                        app_state
112                            .add_connection_to_server(
113                                server_id,
114                                ServerConnectionState {
115                                    id: connection_id,
116                                    remote_addr: peer_addr,
117                                    local_addr,
118                                    bytes_sent: 0,
119                                    bytes_received: 0,
120                                    packets_sent: 0,
121                                    packets_received: 0,
122                                    last_activity: now,
123                                    status: ConnectionStatus::Active,
124                                    status_changed_at: now,
125                                    protocol_info: ProtocolConnectionInfo::empty(),
126                                },
127                            )
128                            .await;
129                        let _ = status_tx.send("__UPDATE_UI__".to_string());
130                        Log::new(Some(&status_tx))
131                            .info(format!("Zabbix sender connected from {}", peer_addr));
132
133                        let session = Session {
134                            peer_addr,
135                            llm_client: llm_client.clone(),
136                            app_state: app_state.clone(),
137                            status_tx: status_tx.clone(),
138                            server_id,
139                            protocol: protocol.clone(),
140                            connection_id,
141                            first_timeout,
142                            idle_timeout,
143                        };
144                        // Tracked, not detached: stop_server must abort this task too.
145                        let task_owner = app_state.clone();
146                        task_owner
147                            .spawn_server_task(server_id, async move {
148                                let _permit = permit;
149                                session.run(socket).await
150                            })
151                            .await;
152                    }
153                    Err(e) => {
154                        Log::new(Some(&status_tx)).error(format!("Zabbix accept error: {}", e));
155                        break;
156                    }
157                }
158            }
159        });
160
161        task_registrar
162            .register_server_task(server_id, accept_handle)
163            .await;
164        Ok(local_addr)
165    }
166}
167
168struct Session {
169    peer_addr: SocketAddr,
170    llm_client: OllamaClient,
171    app_state: Arc<AppState>,
172    status_tx: mpsc::UnboundedSender<String>,
173    server_id: crate::state::ServerId,
174    protocol: Arc<actions::ZabbixProtocol>,
175    connection_id: ConnectionId,
176    first_timeout: Duration,
177    idle_timeout: Duration,
178}
179
180type Writer = Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>;
181
182impl Session {
183    async fn run(self, socket: tokio::net::TcpStream) {
184        let (mut reader, write_half) = tokio::io::split(socket);
185        let writer: Writer = Arc::new(Mutex::new(write_half));
186
187        // Registered first, so the operator can reach the connection while its request is
188        // parked for them.
189        let peer_rx = crate::server::peer_support::register_peer_channel(
190            &self.app_state,
191            self.server_id,
192            self.connection_id.as_u32(),
193        )
194        .await;
195        crate::server::peer_support::spawn_peer_command_task(
196            peer_rx,
197            self.protocol.clone(),
198            self.app_state.clone(),
199            self.server_id,
200            self.connection_id.as_u32(),
201            writer.clone(),
202            self.status_tx.clone(),
203        );
204
205        self.session(&mut reader, &writer).await;
206
207        self.app_state
208            .remove_peer_handle(self.server_id, self.connection_id.as_u32())
209            .await;
210        let _ = writer.lock().await.shutdown().await;
211        linger(&mut reader).await;
212        self.app_state
213            .update_connection_status(
214                self.server_id,
215                self.connection_id,
216                crate::state::server::ConnectionStatus::Closed,
217            )
218            .await;
219        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
220    }
221
222    async fn write(&self, writer: &Writer, data: &[u8]) {
223        let ok = {
224            let mut w = writer.lock().await;
225            w.write_all(data).await.is_ok() && w.flush().await.is_ok()
226        };
227        if ok {
228            self.app_state
229                .update_connection_stats(
230                    self.server_id,
231                    self.connection_id,
232                    None,
233                    Some(data.len() as u64),
234                    None,
235                    Some(1),
236                )
237                .await;
238        }
239    }
240
241    /// Read until `buf` holds `want` bytes. `first` bounds the wait for the very first byte,
242    /// `self.idle_timeout` every wait after it.
243    async fn fill<R: AsyncRead + Unpin>(
244        &self,
245        reader: &mut R,
246        buf: &mut Vec<u8>,
247        want: usize,
248        first: Option<Duration>,
249    ) -> Result<(), &'static str> {
250        let mut chunk = [0u8; 8192];
251        while buf.len() < want {
252            let timeout = match first {
253                Some(t) if buf.is_empty() => t,
254                _ => self.idle_timeout,
255            };
256            let room = (want - buf.len()).min(chunk.len());
257            match tokio::time::timeout(timeout, reader.read(&mut chunk[..room])).await {
258                Err(_) => return Err("timed out"),
259                Ok(Ok(0)) => return Err("closed"),
260                Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
261                Ok(Err(_)) => return Err("read error"),
262            }
263        }
264        Ok(())
265    }
266
267    async fn session<R: AsyncRead + Unpin>(&self, reader: &mut R, writer: &Writer) {
268        let log = Log::new(Some(&self.status_tx));
269        let started = crate::utils::clock::Instant::now();
270        let mut buf: Vec<u8> = Vec::with_capacity(wire::LARGE_HEADER_LEN);
271
272        // The magic and the flags byte, which says how long the rest of the header is.
273        if let Err(why) = self
274            .fill(reader, &mut buf, 5, Some(self.first_timeout))
275            .await
276        {
277            log.info(format!(
278                "Zabbix peer {} sent no header ({why}); closing",
279                self.peer_addr
280            ));
281            return;
282        }
283        if &buf[..4] != wire::MAGIC {
284            // Not the Zabbix protocol at all: there is no response it would understand.
285            log.warn(format!(
286                "Zabbix peer {} did not send a ZBXD header decision=fail_closed_bad_header",
287                self.peer_addr
288            ));
289            return;
290        }
291        let header_len = wire::header_len(buf[4]);
292        if let Err(why) = self.fill(reader, &mut buf, header_len, None).await {
293            log.info(format!(
294                "Zabbix peer {}: header incomplete ({why}); closing",
295                self.peer_addr
296            ));
297            return;
298        }
299        let header = match wire::parse_header(&buf[..header_len]) {
300            Ok(header) => header,
301            Err(e) => {
302                let (decision, info) = match e {
303                    wire::HeaderError::TooLarge(_) => ("fail_closed_too_large", INFO_TOO_LARGE),
304                    wire::HeaderError::Compressed => ("fail_closed_compressed", INFO_COMPRESSED),
305                    wire::HeaderError::BadFlags(_) | wire::HeaderError::BadMagic => {
306                        ("fail_closed_bad_header", INFO_BAD_FLAGS)
307                    }
308                };
309                log.warn(format!(
310                    "Zabbix peer {}: {:?} decision={} (limit {} bytes)",
311                    self.peer_addr,
312                    e,
313                    decision,
314                    wire::MAX_DATA_BYTES
315                ));
316                self.write(writer, &wire::render_failed(info)).await;
317                return;
318            }
319        };
320
321        // Bounded by parse_header: at most MAX_DATA_BYTES.
322        let total_len = header_len + header.data_len as usize;
323        buf.reserve_exact(total_len - buf.len());
324        if let Err(why) = self.fill(reader, &mut buf, total_len, None).await {
325            log.info(format!(
326                "Zabbix peer {}: request body incomplete ({why}); closing",
327                self.peer_addr
328            ));
329            return;
330        }
331        self.app_state
332            .update_connection_stats(
333                self.server_id,
334                self.connection_id,
335                Some(buf.len() as u64),
336                None,
337                Some(1),
338                None,
339            )
340            .await;
341        let body = &buf[header_len..total_len];
342        log.trace(format!(
343            "Zabbix request from {}: {}",
344            self.peer_addr,
345            String::from_utf8_lossy(body)
346        ));
347
348        let (items, clock) = match wire::parse_request(body) {
349            Ok(wire::Request::SenderData { items, clock }) => (items, clock),
350            Ok(wire::Request::Other(name)) => {
351                log.warn(format!(
352                    "Zabbix peer {} sent request {:?} decision=fail_closed_unsupported_request",
353                    self.peer_addr, name
354                ));
355                self.write(writer, &wire::render_failed(INFO_UNSUPPORTED))
356                    .await;
357                return;
358            }
359            Err(e) => {
360                log.warn(format!(
361                    "Zabbix peer {}: {:?} decision=fail_closed_bad_request",
362                    self.peer_addr, e
363                ));
364                self.write(writer, &wire::render_failed(e.info())).await;
365                return;
366            }
367        };
368        let total = items.len() as u64;
369        if total == 0 {
370            // Nothing to decide: the Zabbix server answers an empty batch the same way.
371            self.write(
372                writer,
373                &wire::render_result(0, 0, 0, started.elapsed().as_secs_f64()),
374            )
375            .await;
376            return;
377        }
378
379        let reply = self.answer_with_model(&items, clock).await;
380        if let Some((processed, failed)) = reply {
381            self.write(
382                writer,
383                &wire::render_result(processed, failed, total, started.elapsed().as_secs_f64()),
384            )
385            .await;
386        }
387    }
388
389    /// Ask the model; `None` means close without answering (the model's own choice).
390    async fn answer_with_model(
391        &self,
392        items: &[wire::SenderItem],
393        clock: Option<i64>,
394    ) -> Option<(u64, u64)> {
395        let log = Log::new(Some(&self.status_tx));
396        let total = items.len() as u64;
397        let items_json: Vec<serde_json::Value> = items
398            .iter()
399            .map(|i| {
400                let mut v = serde_json::json!({"host": i.host, "key": i.key, "value": i.value});
401                if let Some(c) = i.clock {
402                    v["clock"] = c.into();
403                }
404                if let Some(ns) = i.ns {
405                    v["ns"] = ns.into();
406                }
407                v
408            })
409            .collect();
410        let mut data = serde_json::json!({
411            "items": items_json,
412            "item_count": total,
413            "answer_with": actions::answer_with_for_items(items.iter().map(|i| i.host.as_str())),
414        });
415        if let Some(c) = clock {
416            data["clock"] = c.into();
417        }
418        let event = Event::new(&actions::ZABBIX_SENDER_DATA_EVENT, data);
419
420        let result = match call_llm(
421            &self.llm_client,
422            &self.app_state,
423            self.server_id,
424            Some(self.connection_id),
425            &event,
426            self.protocol.as_ref(),
427        )
428        .await
429        {
430            Ok(result) => result,
431            Err(e) => {
432                let category = match crate::utils::WireFailure::classify(&e) {
433                    crate::utils::WireFailure::Overloaded => "overloaded",
434                    crate::utils::WireFailure::Unavailable => "unavailable",
435                };
436                log.warn(format!(
437                    "Zabbix {} value(s) from {} decision=fail_closed_llm_error category={}; \
438                     answering processed: 0",
439                    total, self.peer_addr, category
440                ));
441                log.debug(format!("Zabbix LLM call failed: {}", e));
442                return Some((0, total));
443            }
444        };
445        for message in &result.messages {
446            log.info(message);
447        }
448
449        let mut replies: Vec<Vec<u8>> = Vec::new();
450        let mut close = false;
451        let mut stack = result.protocol_results;
452        stack.reverse();
453        while let Some(item) = stack.pop() {
454            match item {
455                ActionResult::Output(bytes) => replies.push(bytes),
456                ActionResult::CloseConnection => close = true,
457                ActionResult::Multiple(items) => stack.extend(items.into_iter().rev()),
458                _ => {}
459            }
460        }
461
462        let Some(reply) = replies.first() else {
463            if close {
464                log.info(format!(
465                    "Zabbix {} value(s) from {} decision=model_close; closing without an answer",
466                    total, self.peer_addr
467                ));
468                return None;
469            }
470            log.warn(format!(
471                "Zabbix {} value(s) from {} decision=model_silent ({} failed action(s)); \
472                 answering processed: 0",
473                total,
474                self.peer_addr,
475                result.failures.len()
476            ));
477            return Some((0, total));
478        };
479
480        match wire::read_result(reply) {
481            Some((processed, failed, t)) if t == total => {
482                let decision = if processed == 0 {
483                    "model_reject"
484                } else {
485                    "model_answer"
486                };
487                log.info(format!(
488                    "Zabbix {} value(s) from {} decision={} processed={} failed={}",
489                    total, self.peer_addr, decision, processed, failed
490                ));
491                Some((processed, failed))
492            }
493            other => {
494                log.warn(format!(
495                    "Zabbix {} value(s) from {} decision=fail_closed_mismatched_reply \
496                     counts={:?}; answering processed: 0",
497                    total, self.peer_addr, other
498                ));
499                Some((0, total))
500            }
501        }
502    }
503}
504
505/// Keep reading briefly after the response and the half-close, so the close is a FIN rather
506/// than an RST that could destroy the response before the sender reads it. Bounded both ways.
507const LINGER_TIME: Duration = Duration::from_secs(2);
508const LINGER_BYTES: usize = 64 * 1024;
509
510async fn linger<R: AsyncRead + Unpin>(reader: &mut R) {
511    let deadline = tokio::time::Instant::now() + LINGER_TIME;
512    let mut sink = [0u8; 4096];
513    let mut drained = 0usize;
514    while drained < LINGER_BYTES {
515        match tokio::time::timeout_at(deadline, reader.read(&mut sink)).await {
516            Ok(Ok(n)) if n > 0 => drained += n,
517            _ => break,
518        }
519    }
520}