Skip to main content

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}