Skip to main content

netget/client/syslog/
mod.rs

1//! Syslog client implementation
2pub mod actions;
3
4pub use actions::SyslogClientProtocol;
5
6use crate::protocol::StartupParams;
7use anyhow::{anyhow, Context, Result};
8use chrono::Utc;
9use serde_json::Value;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use tokio::io::AsyncWriteExt;
13use tokio::net::{TcpStream, UdpSocket};
14use tokio::sync::{mpsc, Mutex};
15use tracing::{error, info, trace};
16
17use crate::client::llm_budget::call_llm_for_client;
18use crate::llm::actions::client_trait::{Client, ClientActionResult};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26use crate::client::syslog::actions::SYSLOG_CLIENT_CONNECTED_EVENT;
27
28/// Syslog facilities (RFC 5424)
29fn facility_to_number(facility: &str) -> Result<u8> {
30    match facility.to_lowercase().as_str() {
31        "kern" | "kernel" => Ok(0),
32        "user" => Ok(1),
33        "mail" => Ok(2),
34        "daemon" => Ok(3),
35        "auth" => Ok(4),
36        "syslog" => Ok(5),
37        "lpr" => Ok(6),
38        "news" => Ok(7),
39        "uucp" => Ok(8),
40        "cron" => Ok(9),
41        "authpriv" => Ok(10),
42        "ftp" => Ok(11),
43        "ntp" => Ok(12),
44        "security" => Ok(13),
45        "console" => Ok(14),
46        "solaris-cron" => Ok(15),
47        "local0" => Ok(16),
48        "local1" => Ok(17),
49        "local2" => Ok(18),
50        "local3" => Ok(19),
51        "local4" => Ok(20),
52        "local5" => Ok(21),
53        "local6" => Ok(22),
54        "local7" => Ok(23),
55        _ => Err(anyhow!("Unknown syslog facility: {}", facility)),
56    }
57}
58
59/// Syslog severities (RFC 5424)
60fn severity_to_number(severity: &str) -> Result<u8> {
61    match severity.to_lowercase().as_str() {
62        "emerg" | "emergency" | "panic" => Ok(0),
63        "alert" => Ok(1),
64        "crit" | "critical" => Ok(2),
65        "err" | "error" => Ok(3),
66        "warn" | "warning" => Ok(4),
67        "notice" => Ok(5),
68        "info" | "informational" => Ok(6),
69        "debug" => Ok(7),
70        _ => Err(anyhow!("Unknown syslog severity: {}", severity)),
71    }
72}
73
74/// Format a syslog message according to RFC 5424
75fn format_syslog_message(
76    facility: &str,
77    severity: &str,
78    message: &str,
79    hostname: &str,
80    app_name: &str,
81    proc_id: &str,
82    msg_id: &str,
83) -> Result<String> {
84    let facility_num = facility_to_number(facility)?;
85    let severity_num = severity_to_number(severity)?;
86    let priority = (facility_num * 8) + severity_num;
87
88    // RFC 5424 format:
89    // <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
90    let timestamp = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
91    let structured_data = "-"; // No structured data for now
92
93    let formatted = format!(
94        "<{}>1 {} {} {} {} {} {} {}",
95        priority, timestamp, hostname, app_name, proc_id, msg_id, structured_data, message
96    );
97
98    Ok(formatted)
99}
100
101/// Transport protocol for syslog
102enum SyslogTransport {
103    Tcp(Arc<Mutex<TcpStream>>),
104    Udp(Arc<UdpSocket>, SocketAddr),
105}
106
107/// What applying one executed action did. Shared by the LLM path and the command
108/// channel so an injected action reports the same truth the LLM path puts on the
109/// wire.
110enum Applied {
111    /// Bytes really written (and flushed, on TCP) to the transport.
112    Sent(usize),
113    /// The action ran but wrote nothing.
114    Executed(String),
115    /// The session should end.
116    Disconnect,
117}
118
119/// Syslog client that connects to a remote syslog server
120pub struct SyslogClient;
121
122impl SyslogClient {
123    /// Resolve the collector's `host:port` for the UDP transport.
124    ///
125    /// A literal `IP:port` is used as given, with no name lookup. A hostname is resolved and
126    /// its first IPv4 address is taken when it has one, otherwise its first address. UDP has
127    /// no handshake that would reveal an unreachable address and move on to the next, and
128    /// `localhost` commonly resolves to `::1` ahead of `127.0.0.1`, where a collector
129    /// listening on IPv4 alone never receives the message. (The TCP transport resolves
130    /// through `TcpStream::connect`, which tries each address in turn.)
131    async fn resolve_udp_target(remote_addr: &str) -> Result<SocketAddr> {
132        if let Ok(addr) = remote_addr.parse::<SocketAddr>() {
133            return Ok(addr);
134        }
135        let candidates: Vec<SocketAddr> = tokio::net::lookup_host(remote_addr)
136            .await
137            .with_context(|| {
138                format!(
139                    "Could not resolve syslog address {remote_addr:?} (expected host:port, \
140                     e.g. 127.0.0.1:514 or localhost:514)"
141                )
142            })?
143            .collect();
144        candidates
145            .iter()
146            .find(|addr| addr.is_ipv4())
147            .or_else(|| candidates.first())
148            .copied()
149            .with_context(|| format!("Syslog address {remote_addr:?} resolved to no address"))
150    }
151
152    /// Connect to a syslog server with integrated LLM actions
153    pub async fn connect_with_llm_actions(
154        remote_addr: String,
155        llm_client: OllamaClient,
156        app_state: Arc<AppState>,
157        status_tx: mpsc::UnboundedSender<String>,
158        client_id: ClientId,
159        startup_params: Option<StartupParams>,
160    ) -> Result<SocketAddr> {
161        // Parse protocol from startup params (default to UDP)
162        let protocol = startup_params
163            .as_ref()
164            .map(|params| params.get_optional_string("protocol"))
165            .transpose()?
166            .flatten()
167            .unwrap_or_else(|| "udp".to_string())
168            .to_lowercase();
169
170        let transport = match protocol.as_str() {
171            "tcp" => {
172                info!(
173                    "Syslog client {} connecting via TCP to {}",
174                    client_id, remote_addr
175                );
176                let stream = TcpStream::connect(&remote_addr).await.context(format!(
177                    "Failed to connect to syslog server at {}",
178                    remote_addr
179                ))?;
180
181                let local_addr = stream.local_addr()?;
182                let remote_sock_addr = stream.peer_addr()?;
183
184                info!(
185                    "Syslog client {} connected via TCP to {} (local: {})",
186                    client_id, remote_sock_addr, local_addr
187                );
188
189                SyslogTransport::Tcp(Arc::new(Mutex::new(stream)))
190            }
191            "udp" => {
192                info!("Syslog client {} using UDP to {}", client_id, remote_addr);
193
194                let remote_sock_addr = Self::resolve_udp_target(&remote_addr).await?;
195
196                // Bind to local port (ephemeral)
197                let local_bind = if remote_sock_addr.is_ipv6() {
198                    "[::]:0"
199                } else {
200                    "0.0.0.0:0"
201                };
202
203                let socket = UdpSocket::bind(local_bind)
204                    .await
205                    .context("Failed to bind UDP socket")?;
206
207                let local_addr = socket.local_addr()?;
208
209                info!(
210                    "Syslog client {} bound to UDP {} for remote {}",
211                    client_id, local_addr, remote_sock_addr
212                );
213
214                SyslogTransport::Udp(Arc::new(socket), remote_sock_addr)
215            }
216            _ => {
217                return Err(anyhow!(
218                    "Invalid protocol: {}. Must be 'tcp' or 'udp'",
219                    protocol
220                ));
221            }
222        };
223
224        let transport = Arc::new(transport);
225
226        let local_addr = match transport.as_ref() {
227            SyslogTransport::Tcp(stream) => stream.lock().await.local_addr()?,
228            SyslogTransport::Udp(socket, _) => socket.local_addr()?,
229        };
230
231        // Update client state
232        app_state
233            .update_client_status(client_id, ClientStatus::Connected)
234            .await;
235        let _ = status_tx.send(format!(
236            "[CLIENT] Syslog client {} connected via {}",
237            client_id,
238            protocol.to_uppercase()
239        ));
240        let _ = status_tx.send("__UPDATE_UI__".to_string());
241
242        // Command channel: lets the dashboard (and any programmatic caller) inject
243        // actions into this client via AppState::send_to_client. For syslog this is the
244        // whole point of the client - "emit this line now" is a human action, and until
245        // now nothing but the LLM could ask for it.
246        //
247        // Registered BEFORE the connected event is handled: a `manual` routing rule can
248        // park that event at the dashboard for minutes, and until registration the UI
249        // reports "no command channel" - reading as a protocol limitation when it is
250        // only a queue.
251        let command_rx =
252            crate::client::command_support::register_command_channel(&app_state, client_id).await;
253
254        // Syslog has no read loop at all (it is fire-and-forget in both transports), so
255        // the commands are drained by a task of their own rather than a select! arm.
256        let cmd_transport = transport.clone();
257        let cmd_state = app_state.clone();
258        let cmd_status = status_tx.clone();
259        let cmd_task = tokio::spawn(async move {
260            Self::command_loop(command_rx, cmd_transport, client_id, cmd_state, cmd_status).await;
261        });
262        app_state.register_client_task(client_id, cmd_task).await;
263
264        // Call LLM with connected event
265        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
266            let protocol_impl = Arc::new(SyslogClientProtocol::new());
267            let event = Event::new(
268                &SYSLOG_CLIENT_CONNECTED_EVENT,
269                serde_json::json!({
270                    "remote_addr": remote_addr,
271                    "protocol": protocol,
272                }),
273            );
274
275            match call_llm_for_client(
276                &llm_client,
277                &app_state,
278                client_id.to_string(),
279                &instruction,
280                "",
281                Some(&event),
282                protocol_impl.as_ref(),
283                &status_tx,
284            )
285            .await
286            {
287                Ok(ClientLlmResult {
288                    actions,
289                    memory_updates: _,
290                }) => {
291                    // Execute actions
292                    for action in actions {
293                        if let Err(e) = Self::execute_syslog_action(
294                            action,
295                            transport.as_ref(),
296                            client_id,
297                            &status_tx,
298                            protocol_impl.as_ref(),
299                        )
300                        .await
301                        {
302                            error!("Error executing syslog action: {}", e);
303                        }
304                    }
305                }
306                Err(e) => {
307                    error!("LLM error for syslog client {}: {}", client_id, e);
308                }
309            }
310        }
311
312        // Store transport for async actions
313        // Note: In a full implementation, we'd store this in app_state for async action execution
314        // For now, the client is fire-and-forget for UDP, or connection-based for TCP
315
316        Ok(local_addr)
317    }
318
319    /// Execute a syslog action (LLM path: rejection and wire failure are both just
320    /// logged by the caller).
321    async fn execute_syslog_action(
322        action: Value,
323        transport: &SyslogTransport,
324        client_id: ClientId,
325        status_tx: &mpsc::UnboundedSender<String>,
326        protocol: &dyn Client,
327    ) -> Result<Applied> {
328        let result = protocol.execute_action(action)?;
329        Self::apply_action_result(result, transport, client_id, status_tx).await
330    }
331
332    /// Put one executed action on the wire. Shared by the connected-event path and
333    /// injected commands, so the RFC 5424 framing exists exactly once and an injected
334    /// message is byte-identical to an LLM-produced one.
335    async fn apply_action_result(
336        result: ClientActionResult,
337        transport: &SyslogTransport,
338        client_id: ClientId,
339        status_tx: &mpsc::UnboundedSender<String>,
340    ) -> Result<Applied> {
341        match result {
342            ClientActionResult::Custom { name, data } if name == "send_syslog_message" => {
343                let facility = data["facility"].as_str().context("Missing facility")?;
344                let severity = data["severity"].as_str().context("Missing severity")?;
345                let message = data["message"].as_str().context("Missing message")?;
346                let hostname = data["hostname"].as_str().unwrap_or("netget");
347                let app_name = data["app_name"].as_str().unwrap_or("netget");
348                let proc_id = data["proc_id"].as_str().unwrap_or("-");
349                let msg_id = data["msg_id"].as_str().unwrap_or("-");
350
351                let formatted_message = format_syslog_message(
352                    facility, severity, message, hostname, app_name, proc_id, msg_id,
353                )?;
354
355                trace!("Syslog client {} sending: {}", client_id, formatted_message);
356
357                // The byte count reported to an injected caller counts what actually
358                // reached the transport, including the TCP framing newline.
359                let bytes_sent = match transport {
360                    SyslogTransport::Tcp(stream) => {
361                        // TCP: message terminated with newline
362                        let mut stream = stream.lock().await;
363                        stream.write_all(formatted_message.as_bytes()).await?;
364                        stream.write_all(b"\n").await?;
365                        stream.flush().await?;
366                        formatted_message.len() + 1
367                    }
368                    SyslogTransport::Udp(socket, remote_addr) => {
369                        // UDP: send as datagram
370                        socket
371                            .send_to(formatted_message.as_bytes(), remote_addr)
372                            .await?
373                    }
374                };
375
376                info!(
377                    "Syslog client {} sent message: facility={}, severity={}, msg={}",
378                    client_id, facility, severity, message
379                );
380                let _ = status_tx.send(format!(
381                    "[CLIENT] Syslog {} sent: [{}:{}] {}",
382                    client_id, facility, severity, message
383                ));
384
385                Ok(Applied::Sent(bytes_sent))
386            }
387            ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
388                "custom result '{name}' is not a syslog wire verb"
389            ))),
390            ClientActionResult::Disconnect => {
391                info!("Syslog client {} disconnecting", client_id);
392                Ok(Applied::Disconnect)
393            }
394            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
395            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
396            other => Ok(Applied::Executed(format!("no wire effect: {other:?}"))),
397        }
398    }
399
400    /// Drain injected commands until the channel closes (the client was removed) or an
401    /// injected `disconnect` ends the session.
402    ///
403    /// Bespoke rather than `command_support::handle_stream_client_command` because
404    /// `send_syslog_message` yields `ClientActionResult::Custom` and the transport is
405    /// either a datagram socket or a stream - there is no single write half. The
406    /// logging and reply are byte-for-byte what the generic helper does.
407    async fn command_loop(
408        mut command_rx: mpsc::Receiver<ClientCommand>,
409        transport: Arc<SyslogTransport>,
410        client_id: ClientId,
411        app_state: Arc<AppState>,
412        status_tx: mpsc::UnboundedSender<String>,
413    ) {
414        use crate::llm::actions::protocol_trait::Protocol;
415
416        let protocol = SyslogClientProtocol::new();
417
418        while let Some(command) = command_rx.recv().await {
419            let action = command.action.clone();
420            let outcome = match protocol.execute_action(action.clone()) {
421                Err(e) => Ok(ClientSendOutcome::Rejected {
422                    error: e.to_string(),
423                }),
424                Ok(result) => {
425                    Self::apply_action_result(result, transport.as_ref(), client_id, &status_tx)
426                        .await
427                        .map(|applied| match applied {
428                            Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
429                            Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
430                            Applied::Disconnect => ClientSendOutcome::Disconnected,
431                        })
432                }
433            };
434
435            let outcome_json = match &outcome {
436                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
437                Err(e) => serde_json::json!({"error": e.to_string()}),
438            };
439            app_state
440                .record_access_log(
441                    AccessLogOwner::Client(client_id.as_u32()),
442                    protocol.protocol_name(),
443                    None,
444                    "injected_action",
445                    action,
446                    vec![outcome_json],
447                )
448                .await;
449
450            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
451            if let Err(e) = &outcome {
452                error!("Syslog client {} injected action failed: {}", client_id, e);
453                let _ = status_tx.send(format!(
454                    "[WARN] Client {} injected action failed: {}",
455                    client_id, e
456                ));
457            }
458            let _ = status_tx.send("__UPDATE_UI__".to_string());
459            crate::client::command_support::reply(command, outcome);
460
461            if disconnect {
462                if let SyslogTransport::Tcp(stream) = transport.as_ref() {
463                    // Half-close so the collector sees EOF rather than a dangling
464                    // connection.
465                    let _ = stream.lock().await.shutdown().await;
466                }
467                app_state
468                    .update_client_status(client_id, ClientStatus::Disconnected)
469                    .await;
470                let _ = status_tx.send(format!(
471                    "[CLIENT] Syslog client {} disconnected (injected action)",
472                    client_id
473                ));
474                let _ = status_tx.send("__UPDATE_UI__".to_string());
475                break;
476            }
477        }
478
479        // Every exit path lands here: drop the command handle so the dashboard stops
480        // offering [ send ] on a dead client (a late send then fails fast).
481        app_state.remove_client_handle(client_id).await;
482        let _ = status_tx.send("__UPDATE_UI__".to_string());
483    }
484}