1pub 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
28fn 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
59fn 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
74fn 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 let timestamp = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
91 let structured_data = "-"; 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
101enum SyslogTransport {
103 Tcp(Arc<Mutex<TcpStream>>),
104 Udp(Arc<UdpSocket>, SocketAddr),
105}
106
107enum Applied {
111 Sent(usize),
113 Executed(String),
115 Disconnect,
117}
118
119pub struct SyslogClient;
121
122impl SyslogClient {
123 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 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 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 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 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 let command_rx =
252 crate::client::command_support::register_command_channel(&app_state, client_id).await;
253
254 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 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 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 Ok(local_addr)
317 }
318
319 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 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 let bytes_sent = match transport {
360 SyslogTransport::Tcp(stream) => {
361 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 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 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 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 app_state.remove_client_handle(client_id).await;
482 let _ = status_tx.send("__UPDATE_UI__".to_string());
483 }
484}