1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct SyslogProtocol;
16
17impl SyslogProtocol {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23impl Protocol for SyslogProtocol {
25 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
26 vec![forward_syslog_action()]
28 }
29 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
30 vec![
31 store_syslog_message_action(),
32 ignore_syslog_message_action(),
33 ]
34 }
35 fn protocol_name(&self) -> &'static str {
36 "Syslog"
37 }
38 fn get_event_types(&self) -> Vec<EventType> {
39 get_syslog_event_types()
40 }
41 fn stack_name(&self) -> &'static str {
42 "ETH>IP>UDP>SYSLOG"
43 }
44 fn keywords(&self) -> Vec<&'static str> {
45 vec!["syslog"]
46 }
47 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
48 use crate::protocol::metadata::{
49 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
50 };
51
52 ProtocolMetadataV2::builder()
53 .connectionless()
54 .deliberately_silent()
57 .state(DevelopmentState::Experimental)
58 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(514))
59 .well_known_udp_port(514)
60 .implementation("syslog_loose v0.22 for parsing RFC 3164/5424 messages")
61 .llm_control("Message filtering, forwarding, alerting")
62 .e2e_testing("logger command (Linux/macOS built-in)")
63 .notes("RFC 3164 and RFC 5424 over UDP. Receive-only: nothing is ever sent back to the sender. No E2E test coverage")
64 .build()
65 }
66 fn description(&self) -> &'static str {
67 "Syslog server for log aggregation and analysis"
68 }
69 fn example_prompt(&self) -> &'static str {
70 "Syslog Port 514 collect system logs and alert on critical errors"
71 }
72 fn group_name(&self) -> &'static str {
73 "Core"
74 }
75
76 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
77 use crate::llm::actions::StartupExamples;
78 use serde_json::json;
79
80 let script = r#"import json, sys
83data = json.load(sys.stdin)
84event = data["event"]
85if data["event_type_id"] == "syslog_message":
86 actions = [{"type": "store_syslog_message",
87 "message": str(event.get("message", ""))}]
88else:
89 actions = []
90print(json.dumps({"actions": actions}))"#;
91
92 StartupExamples::new(
93 json!({
95 "type": "open_server",
96 "port": 514,
97 "base_stack": "syslog",
98 "instruction": "Syslog server collecting and analyzing system logs"
99 }),
100 json!({
102 "type": "open_server",
103 "port": 514,
104 "base_stack": "syslog",
105 "event_handlers": [{
106 "event_pattern": "syslog_message",
107 "handler": {
108 "type": "script",
109 "language": "python",
110 "code": script
111 }
112 }]
113 }),
114 json!({
116 "type": "open_server",
117 "port": 514,
118 "base_stack": "syslog",
119 "event_handlers": [{
120 "event_pattern": "syslog_message",
121 "handler": {
122 "type": "static",
123 "actions": [{
124 "type": "store_syslog_message",
125 "message": ""
126 }]
127 }
128 }]
129 }),
130 )
131 }
132}
133
134impl Server for SyslogProtocol {
136 fn spawn(
137 &self,
138 ctx: crate::protocol::SpawnContext,
139 ) -> std::pin::Pin<
140 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
141 > {
142 Box::pin(async move {
143 use crate::server::syslog::SyslogServer;
144 SyslogServer::spawn_with_llm_actions(
145 ctx.legacy_listen_addr(),
146 ctx.llm_client,
147 ctx.state,
148 ctx.status_tx,
149 ctx.server_id,
150 )
151 .await
152 })
153 }
154 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
155 let action_type = action
156 .get("type")
157 .and_then(|v| v.as_str())
158 .context("Missing 'type' field in action")?;
159
160 match action_type {
161 "forward_syslog" => self.execute_forward_syslog(action),
162 "store_syslog_message" => self.execute_store_syslog_message(action),
163 "ignore_syslog_message" => Ok(ActionResult::NoAction),
164 _ => Err(anyhow::anyhow!("Unknown Syslog action: {}", action_type)),
165 }
166 }
167}
168
169impl SyslogProtocol {
170 fn execute_forward_syslog(&self, action: serde_json::Value) -> Result<ActionResult> {
176 use std::net::{ToSocketAddrs, UdpSocket};
177
178 let target = action.get("target").and_then(|v| v.as_str()).context(
179 "Missing 'target' parameter (expected \"IP:port\", e.g. \"192.168.1.100:514\")",
180 )?;
181
182 let message = action
183 .get("message")
184 .and_then(|v| v.as_str())
185 .context("Missing 'message' parameter")?;
186
187 let addr = target
188 .to_socket_addrs()
189 .with_context(|| {
190 format!("Invalid 'target' {target:?}: expected \"HOST:PORT\", e.g. \"192.168.1.100:514\"")
191 })?
192 .next()
193 .with_context(|| format!("'target' {target:?} did not resolve to any address"))?;
194
195 let bind_addr = if addr.is_ipv4() {
196 "0.0.0.0:0"
197 } else {
198 "[::]:0"
199 };
200 let socket = UdpSocket::bind(bind_addr)
201 .with_context(|| format!("Failed to open a local UDP socket to forward to {addr}"))?;
202 let sent = socket
203 .send_to(message.as_bytes(), addr)
204 .with_context(|| format!("Failed to forward syslog message to {addr}"))?;
205
206 tracing::debug!("Syslog forwarded {} bytes to {}", sent, addr);
207
208 Ok(ActionResult::NoAction)
210 }
211
212 fn execute_store_syslog_message(&self, action: serde_json::Value) -> Result<ActionResult> {
219 let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
220
221 tracing::debug!("Syslog message retained in access log: {}", message);
222
223 Ok(ActionResult::NoAction)
224 }
225}
226
227fn forward_syslog_action() -> ActionDefinition {
229 ActionDefinition {
230 name: "forward_syslog".to_string(),
231 description: "Relay a syslog message to another syslog collector over UDP. The message is sent exactly as given, so include the '<PRI>' header if the receiver expects a well-formed syslog line - the 'raw_message' field of the syslog_message event is the original datagram and can be forwarded unchanged.".to_string(),
232 parameters: vec![
233 Parameter {
234 name: "target".to_string(),
235 type_hint: "string".to_string(),
236 description: "Destination collector as 'HOST:PORT', e.g. '192.168.1.100:514'. Port is mandatory".to_string(),
237 required: true,
238 },
239 Parameter {
240 name: "message".to_string(),
241 type_hint: "string".to_string(),
242 description: "Exact text of the datagram to send. Pass the event's 'raw_message' to relay it untouched, or build a new syslog line such as '<34>Oct 11 22:14:15 host app: text'".to_string(),
243 required: true,
244 },
245 ],
246 example: json!({
247 "type": "forward_syslog",
248 "target": "192.168.1.100:514",
249 "message": "<34>Oct 11 22:14:15 mymachine su: 'su root' failed for user on /dev/pts/8"
250 }),
251 log_template: Some(
252 LogTemplate::new()
253 .with_info("-> Syslog forwarded to {target}")
254 .with_debug("Syslog forward_syslog: target={target}"),
255 ),
256 }
257}
258
259fn store_syslog_message_action() -> ActionDefinition {
261 ActionDefinition {
262 name: "store_syslog_message".to_string(),
263 description: "Keep this log line - unless the instruction says to drop messages like it, in which case the answer is ignore_syslog_message instead, never both. The action and its message are written to the server's access log, which is readable later with the list_access_logs / get_access_log tools. NetGet does not write syslog messages to a database or to disk, and nothing is sent back to the client. Use forward_syslog if the message must reach another collector.".to_string(),
264 parameters: vec![Parameter {
265 name: "message".to_string(),
266 type_hint: "string".to_string(),
267 description: "Text to record. Usually the event's 'raw_message' (the original datagram) or its 'message' field, optionally annotated with your own note".to_string(),
268 required: true,
269 }],
270 example: json!({
271 "type": "store_syslog_message",
272 "message": "<34>Oct 11 22:14:15 mymachine su: 'su root' failed for user on /dev/pts/8"
273 }),
274 log_template: Some(
275 LogTemplate::new()
276 .with_info("-> Syslog stored")
277 .with_debug("Syslog store_syslog_message"),
278 ),
279 }
280}
281
282fn ignore_syslog_message_action() -> ActionDefinition {
284 ActionDefinition {
285 name: "ignore_syslog_message".to_string(),
286 description: "Drop this message: nothing is kept. The answer whenever the instruction says to throw away, discard, ignore or filter out messages like this one - e.g. told to drop anything mentioning healthcheck, a message whose text mentions healthcheck gets this and nothing else. One of store_syslog_message or ignore_syslog_message per message, never both.".to_string(),
287 parameters: vec![Parameter {
293 name: "reason".to_string(),
294 type_hint: "string".to_string(),
295 description: "Why this message is dropped, e.g. 'mentions healthcheck'. Logged, \
296 never stored."
297 .to_string(),
298 required: false,
299 }],
300 example: json!({
301 "type": "ignore_syslog_message",
302 "reason": "matches the drop rule in the instruction"
303 }),
304 log_template: Some(
305 LogTemplate::new()
306 .with_info("-> Syslog ignored")
307 .with_debug("Syslog ignore_syslog_message: {reason}"),
308 ),
309 }
310}
311
312pub static SYSLOG_MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
317 EventType::new(
318 "syslog_message",
319 "Syslog client sent a log message. Syslog is one-way: no response is ever sent back to the client, so the only useful actions are recording, forwarding or dropping the message",
320 json!({"type": "ignore_syslog_message", "reason": "matches the drop rule in the instruction"}),
324 )
325 .with_parameters(vec![
326 Parameter {
327 name: "facility".to_string(),
328 type_hint: "string".to_string(),
329 description: "Facility name, exactly one of: kern, user, mail, daemon, auth, syslog, lpr, news, uucp, cron, authpriv, ftp, ntp, audit, alert, clockd, local0-local7. Defaults to 'user' when the datagram carried no <PRI> header".to_string(),
330 required: true,
331 },
332 Parameter {
333 name: "facility_code".to_string(),
334 type_hint: "number".to_string(),
335 description: "Numeric facility, 0 (kern) to 23 (local7)".to_string(),
336 required: true,
337 },
338 Parameter {
339 name: "severity".to_string(),
340 type_hint: "string".to_string(),
341 description: "Severity name, exactly one of: emerg, alert, crit, err, warning, notice, info, debug. Defaults to 'notice' when the datagram carried no <PRI> header".to_string(),
342 required: true,
343 },
344 Parameter {
345 name: "severity_code".to_string(),
346 type_hint: "number".to_string(),
347 description: "Numeric severity, 0 = emerg (most severe) to 7 = debug (least severe). Compare against this rather than the name when filtering by importance, e.g. severity_code <= 3 means error or worse".to_string(),
348 required: true,
349 },
350 Parameter {
351 name: "priority".to_string(),
352 type_hint: "number".to_string(),
353 description: "The wire PRI value, facility_code * 8 + severity_code".to_string(),
354 required: true,
355 },
356 Parameter {
357 name: "timestamp".to_string(),
358 type_hint: "string".to_string(),
359 description: "Timestamp reported by the sender in RFC 3339 form, or 'unknown' if the message had none. This is the client's clock, not the server's".to_string(),
360 required: false,
361 },
362 Parameter {
363 name: "hostname".to_string(),
364 type_hint: "string".to_string(),
365 description: "Hostname claimed by the sender, or 'unknown'. Unauthenticated and easily forged - use 'source_ip' for the address the datagram actually came from".to_string(),
366 required: false,
367 },
368 Parameter {
369 name: "appname".to_string(),
370 type_hint: "string".to_string(),
371 description: "Application/tag name (APP-NAME in RFC 5424, the tag before ':' in RFC 3164), or 'unknown'".to_string(),
372 required: false,
373 },
374 Parameter {
375 name: "procid".to_string(),
376 type_hint: "string".to_string(),
377 description: "Process id or name if the sender included one, otherwise null".to_string(),
378 required: false,
379 },
380 Parameter {
381 name: "message".to_string(),
382 type_hint: "string".to_string(),
383 description: "Log text with the syslog header (priority, timestamp, hostname, tag) stripped off".to_string(),
384 required: true,
385 },
386 Parameter {
387 name: "source_ip".to_string(),
388 type_hint: "string".to_string(),
389 description: "IP address the datagram was received from".to_string(),
390 required: true,
391 },
392 Parameter {
393 name: "raw_message".to_string(),
394 type_hint: "string".to_string(),
395 description: "The complete datagram as received, header included. Forward or store this to preserve the message byte-for-byte".to_string(),
396 required: true,
397 },
398 ])
399 .with_alternative_example(json!({"type": "store_syslog_message", "message": "<34>Oct 11 22:14:15 mymachine su: 'su root' failed"}))
400 .with_actions(vec![
401 store_syslog_message_action(),
402 ignore_syslog_message_action(),
403 forward_syslog_action(),
404 ])
405 .with_log_template(
406 LogTemplate::new()
407 .with_info("syslog {facility}.{severity} from {hostname}")
408 .with_debug("syslog {facility}.{severity} from {hostname} ({source_ip}) app={appname}: {message}")
409 .with_trace("syslog: {json_pretty(.)}"),
410 )
411});
412
413pub fn get_syslog_event_types() -> Vec<EventType> {
414 vec![SYSLOG_MESSAGE_EVENT.clone()]
415}