use super::wire;
use crate::llm::actions::{
protocol_trait::{ActionResult, Protocol, Server},
ActionDefinition, Parameter,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;
pub struct ZabbixProtocol;
impl ZabbixProtocol {
pub fn new() -> Self {
Self
}
}
impl Default for ZabbixProtocol {
fn default() -> Self {
Self::new()
}
}
impl Protocol for ZabbixProtocol {
fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
vec![
crate::llm::actions::ParameterDefinition {
name: "first_byte_timeout_secs".to_string(),
type_hint: "number".to_string(),
description: "Seconds a new connection may send nothing before the server \
closes it. Default 30: zabbix_sender writes its whole request the \
moment it connects."
.to_string(),
required: false,
example: json!(30),
default: Some(serde_json::json!(super::FIRST_BYTE_TIMEOUT.as_secs())),
},
crate::llm::actions::ParameterDefinition {
name: "idle_timeout_secs".to_string(),
type_hint: "number".to_string(),
description: "Seconds the server waits for the rest of a request that has \
started arriving. Default 30."
.to_string(),
required: false,
example: json!(30),
default: Some(serde_json::json!(super::IDLE_TIMEOUT.as_secs())),
},
]
}
fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
Vec::new()
}
fn get_sync_actions(&self) -> Vec<ActionDefinition> {
vec![send_result_action(), close_connection_action()]
}
fn protocol_name(&self) -> &'static str {
"Zabbix"
}
fn get_event_types(&self) -> Vec<EventType> {
vec![ZABBIX_SENDER_DATA_EVENT.clone()]
}
fn stack_name(&self) -> &'static str {
"ETH>IP>TCP>Zabbix"
}
fn keywords(&self) -> Vec<&'static str> {
vec!["zabbix", "zabbix trapper", "zabbix_sender", "trapper"]
}
fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
use crate::protocol::metadata::{
DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
};
ProtocolMetadataV2::builder()
.state(DevelopmentState::Beta)
.well_known_port(10051)
.privilege_requirement(PrivilegeRequirement::None)
.implementation(
"Hand-written Zabbix protocol framing (ZBXD header, standard and large \
lengths) over tokio TCP; the sender-data request parsed with serde_json and \
the response and its info string rendered by NetGet",
)
.llm_control(
"Which reported values the trapper accepts: the processed and failed counts \
zabbix_sender prints and bases its exit status on",
)
.e2e_testing(
"tests/server/zabbix/real_client_test.rs drives the real zabbix_sender 7.4 \
(Homebrew `zabbix`, Ubuntu `zabbix-sender`): a single value with -s/-k/-o and \
a batch with -i, asserting on the processed/failed summary it printed and on \
its exit status (0 all processed, 2 some failed), with Wireshark's zabbix \
dissector reading the captured exchange. It fails, never skips, when the \
binary is absent. tests/server/zabbix/e2e_test.rs covers the mocked-model \
path on a raw socket.",
)
.notes(
"Implements the trapper side of the Zabbix protocol for the `sender data` \
request: one request per connection, answered and closed, as the Zabbix \
server does. Other requests (active checks, agent data, zabbix.stats) are \
answered `failed` without consulting the model. Compressed packets (flag \
0x02) are refused: zabbix_sender 7.4 does not send them. Requests are capped \
at 1 MiB (the protocol allows 1 GiB; every byte goes into a model prompt) \
and 1000 values, checked against the declared length before allocation. \
NetGet stores nothing: the model only says how many values it accepted. On \
backend failure or an answer whose counts do not add up to the request, the \
sender is told `processed: 0; failed: N` — which makes zabbix_sender exit 2 \
— rather than a `failed` response, on which zabbix_sender 7.4 exits 0.",
)
.max_inbound_bytes(wire::MAX_DATA_BYTES)
.answers_on_failure()
.build()
}
fn description(&self) -> &'static str {
"Zabbix trapper (zabbix_sender) - the model decides which reported values it accepts"
}
fn example_prompt(&self) -> &'static str {
"Zabbix trapper on port 10051 - accept every value except those for unknown hosts"
}
fn group_name(&self) -> &'static str {
"Application"
}
fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
use crate::llm::actions::StartupExamples;
StartupExamples::new(
json!({
"type": "open_server",
"port": 10051,
"base_stack": "zabbix",
"instruction": "Zabbix trapper for hosts web1 and db1. Accept every value \
reported for those hosts and count values for any other host \
as failed."
}),
json!({
"type": "open_server",
"port": 10051,
"base_stack": "zabbix",
"event_handlers": [{
"event_pattern": "zabbix_sender_data",
"handler": {
"type": "script",
"language": "python",
"code": "import json, sys\nitems = json.load(sys.stdin)['event'].get('items', [])\nok = sum(1 for i in items if i.get('host') in ('web1', 'db1'))\nprint(json.dumps({'actions': [{'type': 'send_zabbix_result', 'processed': ok, 'failed': len(items) - ok}]}))"
}
}]
}),
json!({
"type": "open_server",
"port": 10051,
"base_stack": "zabbix",
"event_handlers": [{
"event_pattern": "zabbix_sender_data",
"handler": {
"type": "static",
"actions": [{"type": "send_zabbix_result", "processed": 1, "failed": 0}]
}
}]
}),
)
}
}
impl Server for ZabbixProtocol {
fn spawn(
&self,
ctx: crate::protocol::SpawnContext,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
> {
Box::pin(async move {
let secs = |name: &str| -> anyhow::Result<Option<u64>> {
Ok(ctx
.startup_params
.as_ref()
.map(|p| p.get_optional_u64(name))
.transpose()?
.flatten())
};
let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
let idle_timeout_secs = secs("idle_timeout_secs")?;
crate::server::zabbix::ZabbixServer::spawn_with_llm_actions(
ctx.legacy_listen_addr(),
ctx.llm_client,
ctx.state,
ctx.status_tx,
ctx.server_id,
first_byte_timeout_secs,
idle_timeout_secs,
)
.await
})
}
fn execute_action(&self, action: Value) -> Result<ActionResult> {
let action_type = action
.get("type")
.and_then(|v| v.as_str())
.context("Missing 'type' field in action")?;
match action_type {
"send_zabbix_result" => {
let processed = count(&action, "processed")?.context(
"send_zabbix_result needs 'processed', the number of accepted values",
)?;
let failed = count(&action, "failed")?.unwrap_or(0);
let total = processed
.checked_add(failed)
.context("processed + failed overflows")?;
Ok(ActionResult::Output(wire::render_result(
processed, failed, total, 0.0,
)))
}
"close_connection" => Ok(ActionResult::CloseConnection),
_ => Err(anyhow!("Unknown Zabbix action: {}", action_type)),
}
}
}
fn count(action: &Value, name: &str) -> Result<Option<u64>> {
match action.get(name) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
.map(Some)
.with_context(|| format!("'{name}' must be a non-negative integer")),
}
}
fn send_result_action() -> ActionDefinition {
ActionDefinition {
name: "send_zabbix_result".to_string(),
description: "Tell the sender how many of its values were accepted. processed + failed \
must equal the number of items in the event (the example is for a \
request of 3 values; count yours). A value is processed only when your \
instructions accept values for its host (and key); otherwise it failed. \
NetGet writes the response zabbix_sender prints (processed: P; failed: F; \
total: T; seconds spent: S); zabbix_sender exits 0 when failed is 0 and \
2 otherwise."
.to_string(),
parameters: vec![
Parameter {
name: "processed".to_string(),
type_hint: "number".to_string(),
description: "How many values were accepted".to_string(),
required: true,
},
Parameter {
name: "failed".to_string(),
type_hint: "number".to_string(),
description: "How many values were rejected (unknown host, wrong key, bad \
value)"
.to_string(),
required: true,
},
],
example: json!({"type": "send_zabbix_result", "processed": 2, "failed": 1}),
log_template: Some(
LogTemplate::new()
.with_info("-> Zabbix processed {processed}, failed {failed}")
.with_debug("Zabbix send_zabbix_result: processed={processed} failed={failed}"),
),
}
}
fn close_connection_action() -> ActionDefinition {
ActionDefinition {
name: "close_connection".to_string(),
description: "Close the connection without answering; zabbix_sender reports the send \
as failed"
.to_string(),
parameters: vec![],
example: json!({"type": "close_connection"}),
log_template: Some(
LogTemplate::new()
.with_info("Zabbix connection closed")
.with_debug("Zabbix close_connection"),
),
}
}
pub fn answer_with_for_items<'a>(hosts: impl IntoIterator<Item = &'a str>) -> String {
let mut per_host: Vec<(&str, u64)> = Vec::new();
let mut total = 0u64;
for host in hosts {
total += 1;
match per_host.iter_mut().find(|(h, _)| *h == host) {
Some((_, n)) => *n += 1,
None => per_host.push((host, 1)),
}
}
let listed = per_host
.iter()
.map(|(host, n)| {
let values = if *n == 1 { "value" } else { "values" };
format!("{n} {values} from host \"{host}\"")
})
.collect::<Vec<_>>()
.join(", ");
if let [(host, n)] = per_host.as_slice() {
return format!(
"this request carries {listed}. First look in your instructions for the host \
\"{host}\". If they do not accept values from it - including when they name the \
hosts they monitor and \"{host}\" is not one of them - answer exactly \
{{\"type\": \"send_zabbix_result\", \"processed\": 0, \"failed\": {n}}}. \
Only if they accept values from \"{host}\", answer exactly {{\"type\": \
\"send_zabbix_result\", \"processed\": {n}, \"failed\": 0}}"
);
}
format!(
"this request carries {listed}. For each host, first look in your instructions: are \
values for that host accepted? Count its values as processed if they are and as \
failed if they are not (a host they do not name is not accepted when they name the \
hosts they monitor). Then send_zabbix_result where processed + failed = {total}"
)
}
pub static ZABBIX_SENDER_DATA_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"zabbix_sender_data",
"A sender (zabbix_sender or an application) reported values for trapper items. Decide \
how many you accept and answer with send_zabbix_result; processed + failed must equal \
item_count.",
json!({"type": "send_zabbix_result", "processed": 2, "failed": 0}),
)
.with_parameters(vec![
Parameter {
name: "items".to_string(),
type_hint: "array".to_string(),
description: "The reported values: [{host, key, value, clock?, ns?}], value as \
text, clock in Unix seconds when the sender gave one"
.to_string(),
required: true,
},
Parameter {
name: "item_count".to_string(),
type_hint: "number".to_string(),
description: "How many values the request carries".to_string(),
required: true,
},
Parameter {
name: "answer_with".to_string(),
type_hint: "string".to_string(),
description: "The counts this request takes: the hosts it reports for, and that \
processed + failed must add up to item_count"
.to_string(),
required: true,
},
Parameter {
name: "clock".to_string(),
type_hint: "number".to_string(),
description: "When the sender sent the request (Unix seconds), if it said".to_string(),
required: false,
},
])
.with_log_template(
LogTemplate::new()
.with_info("Zabbix sender data: {item_count} value(s)")
.with_debug("Zabbix zabbix_sender_data: item_count={item_count}"),
)
.with_actions(vec![send_result_action(), close_connection_action()])
.with_alternative_example(json!({"type": "send_zabbix_result", "processed": 0, "failed": 2}))
});