netget/server/syslog/mod.rs
1//! Syslog server implementation using syslog_loose library
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10use tracing::{debug, error, info};
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::ollama_client::OllamaClient;
14use crate::protocol::Event;
15use crate::server::SyslogProtocol;
16use crate::state::app_state::AppState;
17use crate::{console_debug, console_error, console_info, console_trace};
18use actions::SYSLOG_MESSAGE_EVENT;
19
20/// Syslog server that forwards messages to LLM
21pub struct SyslogServer;
22
23impl SyslogServer {
24 /// Spawn Syslog server with integrated LLM actions
25 pub async fn spawn_with_llm_actions(
26 listen_addr: SocketAddr,
27 llm_client: OllamaClient,
28 app_state: Arc<AppState>,
29 status_tx: mpsc::UnboundedSender<String>,
30 server_id: crate::state::ServerId,
31 ) -> Result<SocketAddr> {
32 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
33 let local_addr = socket.local_addr()?;
34 console_info!(status_tx, "Syslog server listening on {}", local_addr);
35
36 let protocol = Arc::new(SyslogProtocol::new());
37
38 let task_registrar = app_state.clone();
39 let accept_handle = tokio::spawn(async move {
40 let mut buffer = vec![0u8; 65535]; // Max UDP packet size
41
42 loop {
43 match socket.recv_from(&mut buffer).await {
44 Ok((n, peer_addr)) => {
45 let data = buffer[..n].to_vec();
46 let connection_id =
47 ConnectionId::new(app_state.get_next_unified_id().await);
48
49 // Add connection to ServerInstance (Syslog "connection" = recent peer)
50 use crate::state::server::{
51 ConnectionState as ServerConnectionState, ConnectionStatus,
52 ProtocolConnectionInfo,
53 };
54 let now = crate::utils::clock::Instant::now();
55 let conn_state = ServerConnectionState {
56 id: connection_id,
57 remote_addr: peer_addr,
58 local_addr,
59 bytes_sent: 0,
60 bytes_received: n as u64,
61 packets_sent: 0,
62 packets_received: 1,
63 last_activity: now,
64 status: ConnectionStatus::Active,
65 status_changed_at: now,
66 protocol_info: ProtocolConnectionInfo::empty(),
67 };
68 app_state
69 .add_connection_to_server(server_id, conn_state)
70 .await;
71 let _ = status_tx.send("__UPDATE_UI__".to_string());
72
73 // DEBUG: Log summary
74 console_debug!(status_tx, "Syslog received {} bytes from {}", n, peer_addr);
75
76 // TRACE: Log full payload
77 let message_str = String::from_utf8_lossy(&data);
78 console_trace!(status_tx, "Syslog message: {}", message_str);
79
80 // Parse the syslog message. syslog_loose is lenient: an unparseable
81 // datagram still yields a message with default facility/severity, so
82 // this does not drop traffic.
83 let parsed = Self::parse_syslog_message(&data);
84
85 let llm_clone = llm_client.clone();
86 let state_clone = app_state.clone();
87 let status_clone = status_tx.clone();
88 let protocol_clone = protocol.clone();
89
90 // Spawn task to handle message with LLM
91 // Tracked, not detached: stop_server must abort this task too.
92 let task_owner = app_state.clone();
93 task_owner.spawn_server_task(server_id, async move {
94 // Create syslog_message event
95 let event = Event::new(
96 &SYSLOG_MESSAGE_EVENT,
97 serde_json::json!({
98 "facility": parsed.facility,
99 "facility_code": parsed.facility_code,
100 "severity": parsed.severity,
101 "severity_code": parsed.severity_code,
102 "priority": parsed.priority,
103 "timestamp": parsed.timestamp,
104 "hostname": parsed.hostname,
105 "appname": parsed.appname,
106 "procid": parsed.procid,
107 "message": parsed.message,
108 "source_ip": peer_addr.ip().to_string(),
109 "raw_message": parsed.raw
110 }),
111 );
112
113 debug!("Syslog calling LLM for message from {}", peer_addr);
114 let _ = status_clone.send(format!(
115 "[DEBUG] Syslog calling LLM for message from {}",
116 peer_addr
117 ));
118
119 // Call LLM
120 match call_llm(
121 &llm_clone,
122 &state_clone,
123 server_id,
124 None,
125 &event,
126 protocol_clone.as_ref(),
127 )
128 .await
129 {
130 Ok(execution_result) => {
131 // Display messages from LLM
132 for message in &execution_result.messages {
133 info!("{}", message);
134 let _ = status_clone.send(format!("[INFO] {}", message));
135 }
136
137 // Syslog is one-way: nothing is ever written back to the
138 // sender (RFC 5426 has no acknowledgement), so the only
139 // record of what happened is the log. Keep the three
140 // outcomes greppable and distinct: the model explicitly
141 // dropped the message, the model answered nothing at all,
142 // or it asked for real work.
143 let decision = Self::decision_tag(&execution_result);
144 debug!(
145 "Syslog message from {} decision={} ({} protocol results, {} failed actions)",
146 peer_addr,
147 decision,
148 execution_result.protocol_results.len(),
149 execution_result.failures.len()
150 );
151 let _ = status_clone.send(format!(
152 "[DEBUG] Syslog message from {} decision={} ({} protocol results)",
153 peer_addr,
154 decision,
155 execution_result.protocol_results.len()
156 ));
157 }
158 Err(e) => {
159 // The peer is not told: syslog has no reply message, and
160 // inventing one would be a protocol violation. The error
161 // therefore goes to the log and the operator status stream
162 // only — never to the wire — with the same category split
163 // (`WireFailure`) the answering protocols put on the wire,
164 // so an overload is distinguishable from a hard failure.
165 let category = crate::utils::WireFailure::classify(&e);
166 let category = if category.is_overloaded() {
167 "overloaded"
168 } else {
169 "unavailable"
170 };
171 error!(
172 "Syslog message from {} decision=fail_closed_llm_error category={} (dropped, no reply is possible on syslog): {}",
173 peer_addr, category, e
174 );
175 let _ = status_clone.send(format!(
176 "✗ Syslog message from {} decision=fail_closed_llm_error category={} (dropped): {}",
177 peer_addr, category, e
178 ));
179 }
180 }
181 }).await;
182 }
183 Err(e) => {
184 console_error!(status_tx, "Syslog receive error: {}", e);
185 break;
186 }
187 }
188 }
189 });
190
191 task_registrar
192 .register_server_task(server_id, accept_handle)
193 .await;
194
195 Ok(local_addr)
196 }
197
198 /// Classify what the model actually decided about one datagram, for the log.
199 ///
200 /// Syslog cannot answer its sender, so "the model said drop it", "the model said
201 /// nothing" and "the LLM call failed" are indistinguishable on the wire — all three
202 /// are silence. They must not be indistinguishable in the log as well: the first is a
203 /// decision, the other two are netget failing to make one. The error case is tagged at
204 /// the call site (`decision=fail_closed_llm_error category=…`); this covers the
205 /// successful-call cases.
206 ///
207 /// The tokens are stable so an operator can grep `decision=no_answer` /
208 /// `decision=fail_closed_` for every message netget did not really handle.
209 fn decision_tag(result: &crate::llm::ExecutionResult) -> &'static str {
210 if result.raw_actions.is_empty() {
211 // No actions at all: the model produced nothing usable. Dropping is what
212 // syslog does anyway, but it was not a decision.
213 return "no_answer";
214 }
215 let all_ignored = result.raw_actions.iter().all(|action| {
216 action.get("type").and_then(|v| v.as_str()) == Some("ignore_syslog_message")
217 });
218 if all_ignored {
219 "model_drop"
220 } else {
221 "model_handled"
222 }
223 }
224
225 /// Parse a syslog datagram into the fields exposed to the LLM.
226 ///
227 /// `syslog_loose` is deliberately lenient and never fails: a datagram that matches
228 /// neither RFC 3164 nor RFC 5424 comes back as a message with no priority, in which
229 /// case we fall back to the RFC 5424 default priority 13 (user.notice).
230 ///
231 /// The facility/severity strings are the library's canonical short names
232 /// (`kern`, `user`, … / `emerg`, `alert`, `crit`, `err`, `warning`, `notice`,
233 /// `info`, `debug`), *not* the `{:?}` form (`LOG_KERN`, `SEV_ERR`), so that an
234 /// event handler can compare them against the names used in the event docs.
235 pub fn parse_syslog_message(data: &[u8]) -> ParsedSyslogInfo {
236 use syslog_loose::{parse_message, ProcId, SyslogFacility, SyslogSeverity, Variant};
237
238 // Convert bytes to string
239 let message_str = String::from_utf8_lossy(data);
240
241 // Parse using syslog_loose (handles both RFC 3164 and RFC 5424)
242 let parsed = parse_message(&message_str, Variant::Either);
243
244 // Facility/severity, with the RFC 5424 default (user.notice) when the datagram
245 // carried no `<pri>` header.
246 let facility = parsed.facility.unwrap_or(SyslogFacility::LOG_USER);
247 let severity = parsed.severity.unwrap_or(SyslogSeverity::SEV_NOTICE);
248 let facility_code = facility as u8;
249 let severity_code = severity as u8;
250
251 // Format timestamp
252 let timestamp = match &parsed.timestamp {
253 Some(ts) => ts.to_rfc3339(),
254 None => "unknown".to_string(),
255 };
256
257 // Extract hostname
258 let hostname = parsed
259 .hostname
260 .map(|s| s.to_string())
261 .unwrap_or_else(|| "unknown".to_string());
262
263 // Extract app name (tag for RFC 3164, APP-NAME for RFC 5424)
264 let appname = match &parsed.appname {
265 Some(name) => name.to_string(),
266 None => "unknown".to_string(),
267 };
268
269 // Extract process ID if available
270 let procid = match &parsed.procid {
271 Some(ProcId::PID(pid)) => Some(format!("{}", pid)),
272 Some(ProcId::Name(name)) => Some(name.to_string()),
273 None => None,
274 };
275
276 ParsedSyslogInfo {
277 facility: facility.as_str().to_string(),
278 facility_code,
279 severity: severity.as_str().to_string(),
280 severity_code,
281 priority: (facility_code as u16) * 8 + severity_code as u16,
282 timestamp,
283 hostname,
284 appname,
285 procid,
286 message: parsed.msg.to_string(),
287 raw: message_str.to_string(),
288 }
289 }
290}
291
292/// Parsed syslog message information
293#[derive(Debug)]
294pub struct ParsedSyslogInfo {
295 /// Canonical facility name (`kern`, `user`, `auth`, `local0`, …)
296 pub facility: String,
297 /// Numeric facility, 0-23
298 pub facility_code: u8,
299 /// Canonical severity name (`emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug`)
300 pub severity: String,
301 /// Numeric severity, 0 (most severe) - 7 (least severe)
302 pub severity_code: u8,
303 /// PRI value as it appears on the wire: facility * 8 + severity
304 pub priority: u16,
305 pub hostname: String,
306 pub appname: String,
307 pub procid: Option<String>,
308 pub timestamp: String,
309 /// Message text with the syslog header stripped
310 pub message: String,
311 /// The complete datagram as received, header included
312 pub raw: String,
313}