]>
Commit | Line | Data |
---|---|---|
b421a7ca LW |
1 | use regex::Regex; |
2 | use std::collections::HashSet; | |
3 | use std::fmt; | |
4 | use std::fmt::Debug; | |
5 | use std::str::FromStr; | |
6 | ||
7 | use serde::{Deserialize, Serialize}; | |
8 | ||
9 | use proxmox_schema::api_types::COMMENT_SCHEMA; | |
10 | use proxmox_schema::{ | |
11 | api, const_regex, ApiStringFormat, Schema, StringSchema, Updater, SAFE_ID_REGEX_STR, | |
12 | }; | |
bdbd55cc | 13 | use proxmox_time::{parse_daily_duration, DailyDuration}; |
b421a7ca LW |
14 | |
15 | use crate::schema::ENTITY_NAME_SCHEMA; | |
16 | use crate::{Error, Notification, Severity}; | |
17 | ||
18 | pub const MATCHER_TYPENAME: &str = "matcher"; | |
19 | ||
20 | #[api] | |
21 | #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)] | |
22 | #[serde(rename_all = "kebab-case")] | |
23 | pub enum MatchModeOperator { | |
24 | /// All match statements have to match (AND) | |
25 | #[default] | |
26 | All, | |
27 | /// At least one filter property has to match (OR) | |
28 | Any, | |
29 | } | |
30 | ||
31 | impl MatchModeOperator { | |
32 | /// Apply the mode operator to two bools, lhs and rhs | |
33 | fn apply(&self, lhs: bool, rhs: bool) -> bool { | |
34 | match self { | |
35 | MatchModeOperator::All => lhs && rhs, | |
36 | MatchModeOperator::Any => lhs || rhs, | |
37 | } | |
38 | } | |
39 | ||
40 | // https://en.wikipedia.org/wiki/Identity_element | |
41 | fn neutral_element(&self) -> bool { | |
42 | match self { | |
43 | MatchModeOperator::All => true, | |
44 | MatchModeOperator::Any => false, | |
45 | } | |
46 | } | |
47 | } | |
48 | ||
49 | const_regex! { | |
50 | pub MATCH_FIELD_ENTRY_REGEX = concat!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR!(), r")=(.*)$"); | |
51 | } | |
52 | ||
53 | pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat = | |
54 | ApiStringFormat::VerifyFn(verify_field_matcher); | |
55 | ||
56 | fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> { | |
57 | let _: FieldMatcher = s.parse()?; | |
58 | Ok(()) | |
59 | } | |
60 | ||
61 | pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.") | |
62 | .format(&MATCH_FIELD_ENTRY_FORMAT) | |
63 | .min_length(1) | |
64 | .max_length(1024) | |
65 | .schema(); | |
66 | ||
67 | #[api( | |
68 | properties: { | |
69 | name: { | |
70 | schema: ENTITY_NAME_SCHEMA, | |
71 | }, | |
72 | comment: { | |
73 | optional: true, | |
74 | schema: COMMENT_SCHEMA, | |
75 | }, | |
76 | "match-field": { | |
77 | type: Array, | |
78 | items: { | |
79 | description: "Fields to match", | |
80 | type: String | |
81 | }, | |
82 | optional: true, | |
83 | }, | |
84 | "match-severity": { | |
85 | type: Array, | |
86 | items: { | |
87 | description: "Severity level to match.", | |
88 | type: String | |
89 | }, | |
90 | optional: true, | |
91 | }, | |
bdbd55cc LW |
92 | "match-calendar": { |
93 | type: Array, | |
94 | items: { | |
95 | description: "Time stamps to match", | |
96 | type: String | |
97 | }, | |
98 | optional: true, | |
99 | }, | |
b421a7ca LW |
100 | "target": { |
101 | type: Array, | |
102 | items: { | |
103 | schema: ENTITY_NAME_SCHEMA, | |
104 | }, | |
105 | optional: true, | |
106 | }, | |
107 | })] | |
108 | #[derive(Debug, Serialize, Deserialize, Updater, Default)] | |
109 | #[serde(rename_all = "kebab-case")] | |
110 | /// Config for Sendmail notification endpoints | |
111 | pub struct MatcherConfig { | |
112 | /// Name of the matcher | |
113 | #[updater(skip)] | |
114 | pub name: String, | |
115 | ||
116 | /// List of matched metadata fields | |
117 | #[serde(skip_serializing_if = "Option::is_none")] | |
118 | pub match_field: Option<Vec<FieldMatcher>>, | |
119 | ||
120 | /// List of matched severity levels | |
121 | #[serde(skip_serializing_if = "Option::is_none")] | |
122 | pub match_severity: Option<Vec<SeverityMatcher>>, | |
123 | ||
bdbd55cc LW |
124 | /// List of matched severity levels |
125 | #[serde(skip_serializing_if = "Option::is_none")] | |
126 | pub match_calendar: Option<Vec<CalendarMatcher>>, | |
127 | ||
b421a7ca LW |
128 | /// Decide if 'all' or 'any' match statements must match |
129 | #[serde(skip_serializing_if = "Option::is_none")] | |
130 | pub mode: Option<MatchModeOperator>, | |
131 | ||
132 | /// Invert match of the whole filter | |
133 | #[serde(skip_serializing_if = "Option::is_none")] | |
134 | pub invert_match: Option<bool>, | |
135 | ||
136 | /// Targets to notify | |
137 | #[serde(skip_serializing_if = "Option::is_none")] | |
138 | pub target: Option<Vec<String>>, | |
139 | ||
140 | /// Comment | |
141 | #[serde(skip_serializing_if = "Option::is_none")] | |
142 | pub comment: Option<String>, | |
306f4005 LW |
143 | |
144 | /// Disable this matcher | |
145 | #[serde(skip_serializing_if = "Option::is_none")] | |
146 | pub disable: Option<bool>, | |
b421a7ca LW |
147 | } |
148 | ||
190d483b LW |
149 | trait MatchDirective { |
150 | fn matches(&self, notification: &Notification) -> Result<bool, Error>; | |
151 | } | |
152 | ||
153 | /// Check if the notification metadata fields match | |
b421a7ca LW |
154 | #[derive(Clone, Debug)] |
155 | pub enum FieldMatcher { | |
156 | Exact { | |
157 | field: String, | |
158 | matched_value: String, | |
159 | }, | |
160 | Regex { | |
161 | field: String, | |
162 | matched_regex: Regex, | |
163 | }, | |
164 | } | |
165 | ||
166 | proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher); | |
167 | proxmox_serde::forward_serialize_to_display!(FieldMatcher); | |
168 | ||
190d483b LW |
169 | impl MatchDirective for FieldMatcher { |
170 | fn matches(&self, notification: &Notification) -> Result<bool, Error> { | |
171 | Ok(match self { | |
b421a7ca LW |
172 | FieldMatcher::Exact { |
173 | field, | |
174 | matched_value, | |
175 | } => { | |
176 | let value = notification.metadata.additional_fields.get(field); | |
177 | ||
178 | if let Some(value) = value { | |
179 | matched_value == value | |
180 | } else { | |
181 | // Metadata field does not exist, so we do not match | |
182 | false | |
183 | } | |
184 | } | |
185 | FieldMatcher::Regex { | |
186 | field, | |
187 | matched_regex, | |
188 | } => { | |
189 | let value = notification.metadata.additional_fields.get(field); | |
190 | ||
191 | if let Some(value) = value { | |
192 | matched_regex.is_match(value) | |
193 | } else { | |
194 | // Metadata field does not exist, so we do not match | |
195 | false | |
196 | } | |
197 | } | |
190d483b | 198 | }) |
b421a7ca LW |
199 | } |
200 | } | |
201 | ||
202 | impl fmt::Display for FieldMatcher { | |
203 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
204 | // Attention, Display is used to implement Serialize, do not | |
205 | // change the format. | |
206 | ||
207 | match self { | |
208 | FieldMatcher::Exact { | |
209 | field, | |
210 | matched_value, | |
211 | } => { | |
212 | write!(f, "exact:{field}={matched_value}") | |
213 | } | |
214 | FieldMatcher::Regex { | |
215 | field, | |
216 | matched_regex, | |
217 | } => { | |
218 | let re = matched_regex.as_str(); | |
219 | write!(f, "regex:{field}={re}") | |
220 | } | |
221 | } | |
222 | } | |
223 | } | |
224 | ||
225 | impl FromStr for FieldMatcher { | |
226 | type Err = Error; | |
227 | fn from_str(s: &str) -> Result<Self, Error> { | |
228 | if !MATCH_FIELD_ENTRY_REGEX.is_match(s) { | |
229 | return Err(Error::FilterFailed(format!( | |
230 | "invalid match-field statement: {s}" | |
231 | ))); | |
232 | } | |
233 | ||
234 | if let Some(remaining) = s.strip_prefix("regex:") { | |
235 | match remaining.split_once('=') { | |
236 | None => Err(Error::FilterFailed(format!( | |
237 | "invalid match-field statement: {s}" | |
238 | ))), | |
239 | Some((field, expected_value_regex)) => { | |
240 | let regex = Regex::new(expected_value_regex) | |
241 | .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?; | |
242 | ||
243 | Ok(Self::Regex { | |
244 | field: field.into(), | |
245 | matched_regex: regex, | |
246 | }) | |
247 | } | |
248 | } | |
249 | } else if let Some(remaining) = s.strip_prefix("exact:") { | |
250 | match remaining.split_once('=') { | |
251 | None => Err(Error::FilterFailed(format!( | |
252 | "invalid match-field statement: {s}" | |
253 | ))), | |
254 | Some((field, expected_value)) => Ok(Self::Exact { | |
255 | field: field.into(), | |
256 | matched_value: expected_value.into(), | |
257 | }), | |
258 | } | |
259 | } else { | |
260 | Err(Error::FilterFailed(format!( | |
261 | "invalid match-field statement: {s}" | |
262 | ))) | |
263 | } | |
264 | } | |
265 | } | |
266 | ||
267 | impl MatcherConfig { | |
268 | pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> { | |
269 | let mode = self.mode.unwrap_or_default(); | |
270 | ||
271 | let mut is_match = mode.neutral_element(); | |
80c90693 LW |
272 | // If there are no matching directives, the matcher will always match |
273 | let mut no_matchers = true; | |
190d483b LW |
274 | |
275 | if let Some(severity_matchers) = self.match_severity.as_deref() { | |
80c90693 | 276 | no_matchers = false; |
190d483b LW |
277 | is_match = mode.apply( |
278 | is_match, | |
279 | self.check_matches(notification, severity_matchers)?, | |
280 | ); | |
281 | } | |
282 | if let Some(field_matchers) = self.match_field.as_deref() { | |
80c90693 | 283 | no_matchers = false; |
190d483b LW |
284 | is_match = mode.apply(is_match, self.check_matches(notification, field_matchers)?); |
285 | } | |
286 | if let Some(calendar_matchers) = self.match_calendar.as_deref() { | |
80c90693 | 287 | no_matchers = false; |
190d483b LW |
288 | is_match = mode.apply( |
289 | is_match, | |
290 | self.check_matches(notification, calendar_matchers)?, | |
291 | ); | |
292 | } | |
b421a7ca LW |
293 | |
294 | let invert_match = self.invert_match.unwrap_or_default(); | |
295 | ||
80c90693 | 296 | Ok(if is_match != invert_match || no_matchers { |
b421a7ca LW |
297 | Some(self.target.as_deref().unwrap_or_default()) |
298 | } else { | |
299 | None | |
300 | }) | |
301 | } | |
302 | ||
190d483b LW |
303 | /// Check if given `MatchDirectives` match a notification. |
304 | fn check_matches( | |
305 | &self, | |
306 | notification: &Notification, | |
307 | matchers: &[impl MatchDirective], | |
308 | ) -> Result<bool, Error> { | |
b421a7ca LW |
309 | let mode = self.mode.unwrap_or_default(); |
310 | let mut is_match = mode.neutral_element(); | |
311 | ||
190d483b LW |
312 | for field_matcher in matchers { |
313 | is_match = mode.apply(is_match, field_matcher.matches(notification)?); | |
bdbd55cc LW |
314 | } |
315 | ||
316 | Ok(is_match) | |
317 | } | |
b421a7ca | 318 | } |
190d483b LW |
319 | |
320 | /// Match severity of the notification. | |
b421a7ca LW |
321 | #[derive(Clone, Debug)] |
322 | pub struct SeverityMatcher { | |
323 | severities: Vec<Severity>, | |
324 | } | |
325 | ||
326 | proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher); | |
327 | proxmox_serde::forward_serialize_to_display!(SeverityMatcher); | |
328 | ||
190d483b LW |
329 | /// Common trait implemented by all matching directives |
330 | impl MatchDirective for SeverityMatcher { | |
331 | /// Check if this directive matches a given notification | |
332 | fn matches(&self, notification: &Notification) -> Result<bool, Error> { | |
333 | Ok(self.severities.contains(¬ification.metadata.severity)) | |
b421a7ca LW |
334 | } |
335 | } | |
336 | ||
337 | impl fmt::Display for SeverityMatcher { | |
338 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
339 | let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect(); | |
340 | f.write_str(&severities.join(",")) | |
341 | } | |
342 | } | |
343 | ||
344 | impl FromStr for SeverityMatcher { | |
345 | type Err = Error; | |
346 | fn from_str(s: &str) -> Result<Self, Error> { | |
347 | let mut severities = Vec::new(); | |
348 | ||
349 | for element in s.split(',') { | |
350 | let element = element.trim(); | |
351 | let severity: Severity = element.parse()?; | |
352 | ||
353 | severities.push(severity) | |
354 | } | |
355 | ||
356 | Ok(Self { severities }) | |
357 | } | |
358 | } | |
359 | ||
bdbd55cc LW |
360 | /// Match timestamp of the notification. |
361 | #[derive(Clone, Debug)] | |
362 | pub struct CalendarMatcher { | |
363 | schedule: DailyDuration, | |
364 | original: String, | |
365 | } | |
366 | ||
367 | proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher); | |
368 | proxmox_serde::forward_serialize_to_display!(CalendarMatcher); | |
369 | ||
190d483b | 370 | impl MatchDirective for CalendarMatcher { |
bdbd55cc LW |
371 | fn matches(&self, notification: &Notification) -> Result<bool, Error> { |
372 | self.schedule | |
373 | .time_match(notification.metadata.timestamp, false) | |
374 | .map_err(|err| Error::Generic(format!("could not match timestamp: {err}"))) | |
375 | } | |
376 | } | |
377 | ||
378 | impl fmt::Display for CalendarMatcher { | |
379 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
380 | f.write_str(&self.original) | |
381 | } | |
382 | } | |
383 | ||
384 | impl FromStr for CalendarMatcher { | |
385 | type Err = Error; | |
386 | fn from_str(s: &str) -> Result<Self, Error> { | |
387 | let schedule = parse_daily_duration(s) | |
388 | .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?; | |
389 | ||
390 | Ok(Self { | |
391 | schedule, | |
392 | original: s.to_string(), | |
393 | }) | |
394 | } | |
395 | } | |
396 | ||
b421a7ca LW |
397 | #[derive(Serialize, Deserialize)] |
398 | #[serde(rename_all = "kebab-case")] | |
399 | pub enum DeleteableMatcherProperty { | |
306f4005 LW |
400 | Comment, |
401 | Disable, | |
402 | InvertMatch, | |
bdbd55cc | 403 | MatchCalendar, |
306f4005 LW |
404 | MatchField, |
405 | MatchSeverity, | |
b421a7ca | 406 | Mode, |
306f4005 | 407 | Target, |
b421a7ca LW |
408 | } |
409 | ||
410 | pub fn check_matches<'a>( | |
411 | matchers: &'a [MatcherConfig], | |
412 | notification: &Notification, | |
413 | ) -> HashSet<&'a str> { | |
414 | let mut targets = HashSet::new(); | |
415 | ||
416 | for matcher in matchers { | |
306f4005 LW |
417 | if matcher.disable.unwrap_or_default() { |
418 | // Skip this matcher if it is disabled | |
419 | log::info!("skipping disabled matcher '{name}'", name = matcher.name); | |
420 | continue; | |
421 | } | |
422 | ||
b421a7ca LW |
423 | match matcher.matches(notification) { |
424 | Ok(t) => { | |
425 | let t = t.unwrap_or_default(); | |
426 | targets.extend(t.iter().map(|s| s.as_str())); | |
427 | } | |
428 | Err(err) => log::error!("matcher '{matcher}' failed: {err}", matcher = matcher.name), | |
429 | } | |
430 | } | |
431 | ||
432 | targets | |
433 | } | |
434 | ||
435 | #[cfg(test)] | |
436 | mod tests { | |
437 | use super::*; | |
438 | use serde_json::Value; | |
439 | use std::collections::HashMap; | |
440 | ||
441 | #[test] | |
442 | fn test_matching() { | |
443 | let mut fields = HashMap::new(); | |
444 | fields.insert("foo".into(), "bar".into()); | |
445 | ||
446 | let notification = | |
447 | Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields); | |
448 | ||
449 | let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap(); | |
190d483b | 450 | assert!(matcher.matches(¬ification).unwrap()); |
b421a7ca LW |
451 | |
452 | let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap(); | |
190d483b | 453 | assert!(matcher.matches(¬ification).unwrap()); |
b421a7ca LW |
454 | |
455 | let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap(); | |
190d483b | 456 | assert!(!matcher.matches(¬ification).unwrap()); |
b421a7ca LW |
457 | |
458 | assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err()); | |
459 | assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err()); | |
460 | } | |
461 | #[test] | |
462 | fn test_severities() { | |
463 | let notification = Notification::new_templated( | |
464 | Severity::Notice, | |
465 | "test", | |
466 | "test", | |
467 | Value::Null, | |
468 | Default::default(), | |
469 | ); | |
470 | ||
471 | let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap(); | |
190d483b | 472 | assert!(matcher.matches(¬ification).unwrap()); |
b421a7ca | 473 | } |
80c90693 LW |
474 | |
475 | #[test] | |
476 | fn test_empty_matcher_matches_always() { | |
477 | let notification = Notification::new_templated( | |
478 | Severity::Notice, | |
479 | "test", | |
480 | "test", | |
481 | Value::Null, | |
482 | Default::default(), | |
483 | ); | |
484 | ||
485 | for mode in [MatchModeOperator::All, MatchModeOperator::Any] { | |
486 | let config = MatcherConfig { | |
487 | name: "matcher".to_string(), | |
488 | mode: Some(mode), | |
489 | ..Default::default() | |
490 | }; | |
491 | ||
492 | assert!(config.matches(¬ification).unwrap().is_some()) | |
493 | } | |
494 | } | |
b421a7ca | 495 | } |