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