1use std::fmt;
22
23use serde::{Deserialize, Serialize};
24
25pub const PREFIX: &str = "KASL_WEBHOOK_";
27
28const TELEGRAM_API: &str = "https://api.telegram.org";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Kind {
35 Slack,
37 Mattermost,
40 Telegram,
42 Json,
44}
45
46impl Kind {
47 fn parse(raw: &str) -> Result<Self, String> {
48 match raw {
49 "slack" => Ok(Self::Slack),
50 "mattermost" => Ok(Self::Mattermost),
51 "telegram" => Ok(Self::Telegram),
52 "json" => Ok(Self::Json),
53 other => Err(format!("unknown kind `{}`: expected slack, mattermost, telegram or json", first_word(other))),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
60#[sqlx(type_name = "webhook_event")]
61pub enum EventKind {
62 #[serde(rename = "alert.raised")]
63 #[sqlx(rename = "alert.raised")]
64 AlertRaised,
65 #[serde(rename = "alert.acknowledged")]
66 #[sqlx(rename = "alert.acknowledged")]
67 AlertAcknowledged,
68 #[serde(rename = "alert.resolved")]
69 #[sqlx(rename = "alert.resolved")]
70 AlertResolved,
71 #[serde(rename = "day.closed")]
72 #[sqlx(rename = "day.closed")]
73 DayClosed,
74 #[serde(rename = "test")]
75 #[sqlx(rename = "test")]
76 Test,
77}
78
79impl EventKind {
80 pub const SUBSCRIBABLE: [Self; 4] = [Self::AlertRaised, Self::AlertAcknowledged, Self::AlertResolved, Self::DayClosed];
84
85 const DEFAULT: [Self; 3] = [Self::AlertRaised, Self::AlertAcknowledged, Self::AlertResolved];
90
91 pub fn name(self) -> &'static str {
92 match self {
93 Self::AlertRaised => "alert.raised",
94 Self::AlertAcknowledged => "alert.acknowledged",
95 Self::AlertResolved => "alert.resolved",
96 Self::DayClosed => "day.closed",
97 Self::Test => "test",
98 }
99 }
100
101 fn parse(raw: &str) -> Result<Self, String> {
102 Self::SUBSCRIBABLE.into_iter().find(|kind| kind.name() == raw).ok_or_else(|| {
103 let known: Vec<&str> = Self::SUBSCRIBABLE.iter().map(|kind| kind.name()).collect();
104 format!("unknown event `{}`: expected one of {}", first_word(raw), known.join(", "))
105 })
106 }
107}
108
109#[derive(Clone, PartialEq, Eq)]
112pub(super) enum Target {
113 Hook { url: String },
115 Telegram { token: String, chat: String, api: String },
119 Json { url: String, secret: String },
122}
123
124impl fmt::Debug for Target {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.write_str("Target(..)")
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Destination {
134 pub name: String,
136 pub kind: Kind,
137 pub(super) target: Target,
138 pub events: Vec<EventKind>,
140 pub department: Option<String>,
144}
145
146impl Destination {
147 pub fn parse(variable: &str, value: &str) -> Result<Self, String> {
153 let name = label(variable)?;
154 let fail = |reason: String| format!("{variable}: {reason}");
155
156 let tokens = tokenize(value).map_err(fail)?;
157 let mut tokens = tokens.into_iter();
158 let kind = Kind::parse(&tokens.next().ok_or_else(|| fail("is empty; expected a kind and a target".into()))?).map_err(fail)?;
159 let target = tokens.next().ok_or_else(|| fail("names a kind and no target".into()))?;
160
161 let mut events: Option<Vec<EventKind>> = None;
162 let mut department = None;
163 let mut chat = None;
164 let mut secret = None;
165 for option in tokens {
166 let Some((key, value)) = option.split_once('=') else {
167 return Err(fail(format!("`{}` is not an option; options are written key=value", first_word(&option))));
168 };
169 let slot = match key {
170 "events" => {
171 if events.is_some() {
172 return Err(fail("names `events` twice".into()));
173 }
174 let parsed = value
175 .split(',')
176 .map(str::trim)
177 .filter(|name| !name.is_empty())
178 .map(EventKind::parse)
179 .collect::<Result<Vec<_>, _>>()
180 .map_err(fail)?;
181 if parsed.is_empty() {
182 return Err(fail("`events=` lists nothing; leave it out to hear about alerts".into()));
183 }
184 events = Some(parsed);
185 continue;
186 }
187 "department" => &mut department,
188 "chat" => &mut chat,
189 "secret" => &mut secret,
190 other => {
191 return Err(fail(format!(
192 "unknown option `{}`: expected events, department, chat or secret",
193 first_word(other)
194 )));
195 }
196 };
197 if slot.is_some() {
198 return Err(fail(format!("names `{key}` twice")));
199 }
200 if value.is_empty() {
201 return Err(fail(format!("`{key}=` is empty")));
202 }
203 *slot = Some(value.to_string());
204 }
205
206 if chat.is_some() && kind != Kind::Telegram {
210 return Err(fail("`chat=` belongs to a telegram destination".into()));
211 }
212 if secret.is_some() && kind != Kind::Json {
213 return Err(fail("`secret=` belongs to a json destination".into()));
214 }
215
216 let target = match kind {
217 Kind::Slack | Kind::Mattermost => Target::Hook {
218 url: http_url(&target).map_err(fail)?,
219 },
220 Kind::Telegram => {
221 let well_formed = target
225 .split_once(':')
226 .is_some_and(|(bot, key)| !bot.is_empty() && bot.bytes().all(|b| b.is_ascii_digit()) && !key.is_empty());
227 if !well_formed {
228 return Err(fail("the target of a telegram destination is the bot token, `123456:ABC...`".into()));
229 }
230 let chat = chat.ok_or_else(|| fail("a telegram destination needs `chat=` - the chat id the bot posts into".into()))?;
231 Target::Telegram {
232 token: target,
233 chat,
234 api: TELEGRAM_API.to_string(),
235 }
236 }
237 Kind::Json => {
238 let secret = secret.ok_or_else(|| fail("a json destination needs `secret=` - the receiver checks the signature with it".into()))?;
242 Target::Json {
243 url: http_url(&target).map_err(fail)?,
244 secret,
245 }
246 }
247 };
248
249 Ok(Self {
250 name,
251 kind,
252 target,
253 events: events.unwrap_or_else(|| EventKind::DEFAULT.to_vec()),
254 department,
255 })
256 }
257
258 pub fn hears(&self, event: EventKind) -> bool {
260 self.events.contains(&event)
261 }
262
263 pub fn shown_target(&self) -> String {
267 match &self.target {
268 Target::Hook { url } | Target::Json { url, .. } => host(url).to_string(),
269 Target::Telegram { chat, .. } => format!("chat {chat}"),
270 }
271 }
272
273 pub fn redact(&self, text: &str) -> String {
279 let secrets: Vec<&str> = match &self.target {
280 Target::Hook { url } => vec![url.as_str()],
281 Target::Telegram { token, .. } => vec![token.as_str()],
282 Target::Json { url, secret } => vec![url.as_str(), secret.as_str()],
283 };
284 secrets
285 .into_iter()
286 .filter(|secret| !secret.is_empty())
287 .fold(text.to_string(), |text, secret| text.replace(secret, "…"))
288 }
289
290 #[doc(hidden)]
293 pub fn with_telegram_api(mut self, api: &str) -> Self {
294 if let Target::Telegram { api: current, .. } = &mut self.target {
295 *current = api.trim_end_matches('/').to_string();
296 }
297 self
298 }
299}
300
301fn label(variable: &str) -> Result<String, String> {
303 let suffix = variable.strip_prefix(PREFIX).unwrap_or_default();
304 if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') {
305 return Err(format!(
306 "{variable}: a destination is named `{PREFIX}<NAME>`, in capital letters, digits and underscores"
307 ));
308 }
309 Ok(suffix.to_ascii_lowercase().replace('_', "-"))
310}
311
312fn tokenize(value: &str) -> Result<Vec<String>, String> {
315 let mut tokens = Vec::new();
316 let mut current = String::new();
317 let mut quoted = false;
318 let mut started = false;
319 for c in value.chars() {
320 match c {
321 '"' => {
322 quoted = !quoted;
323 started = true;
324 }
325 c if c.is_whitespace() && !quoted => {
326 if started {
327 tokens.push(std::mem::take(&mut current));
328 started = false;
329 }
330 }
331 c => {
332 current.push(c);
333 started = true;
334 }
335 }
336 }
337 if quoted {
338 return Err("has a quote that is never closed".into());
339 }
340 if started {
341 tokens.push(current);
342 }
343 Ok(tokens)
344}
345
346fn first_word(token: &str) -> String {
350 const QUOTABLE: usize = 20;
351 if token.chars().count() <= QUOTABLE {
352 token.to_string()
353 } else {
354 format!("{}…", token.chars().take(8).collect::<String>())
355 }
356}
357
358fn http_url(raw: &str) -> Result<String, String> {
362 let rest = raw.strip_prefix("https://").or_else(|| raw.strip_prefix("http://"));
363 match rest {
364 Some(rest) if !host(raw).is_empty() && !rest.starts_with('/') => Ok(raw.to_string()),
365 _ => Err("the target is not an http(s) address".into()),
366 }
367}
368
369fn host(url: &str) -> &str {
371 let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
372 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
373 let authority = authority.rsplit_once('@').map(|(_, host)| host).unwrap_or(authority);
374 authority.split(':').next().unwrap_or_default()
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 const HOOK: &str = "https://hooks.slack.com/services/T000/B000/SECRETSECRETSECRETSECRET";
382
383 #[test]
384 fn a_slack_hook_hears_about_alerts_by_default() {
385 let destination = Destination::parse("KASL_WEBHOOK_TEAM_CHAT", &format!("slack {HOOK}")).unwrap();
386 assert_eq!(destination.name, "team-chat");
387 assert_eq!(destination.kind, Kind::Slack);
388 assert!(destination.hears(EventKind::AlertRaised));
389 assert!(destination.hears(EventKind::AlertResolved));
390 assert!(
391 !destination.hears(EventKind::DayClosed),
392 "a day closes for everyone every day; hearing that is opted into"
393 );
394 assert_eq!(destination.department, None);
395 }
396
397 #[test]
398 fn options_narrow_what_it_hears_and_about_whom() {
399 let destination = Destination::parse(
400 "KASL_WEBHOOK_CS",
401 &format!(r#"mattermost {HOOK} events=day.closed,alert.raised department="Customer Success""#),
402 )
403 .unwrap();
404 assert_eq!(destination.events, vec![EventKind::DayClosed, EventKind::AlertRaised]);
405 assert_eq!(destination.department.as_deref(), Some("Customer Success"));
406 }
407
408 #[test]
409 fn a_telegram_destination_needs_a_bot_token_and_a_chat() {
410 let destination = Destination::parse("KASL_WEBHOOK_OPS", "telegram 123456:ABC-DEF chat=-100200300").unwrap();
411 assert_eq!(destination.shown_target(), "chat -100200300");
412
413 let error = Destination::parse("KASL_WEBHOOK_OPS", "telegram 123456:ABC-DEF").unwrap_err();
414 assert!(error.contains("chat="), "{error}");
415
416 let error = Destination::parse("KASL_WEBHOOK_OPS", "telegram -100200300 chat=-100200300").unwrap_err();
419 assert!(error.contains("bot token"), "{error}");
420 }
421
422 #[test]
423 fn a_json_destination_is_signed_or_refused() {
424 let error = Destination::parse("KASL_WEBHOOK_PAYROLL", "json https://payroll.example.com/in").unwrap_err();
425 assert!(error.contains("secret="), "{error}");
426 assert!(Destination::parse("KASL_WEBHOOK_PAYROLL", "json https://payroll.example.com/in secret=abc").is_ok());
427 }
428
429 #[test]
430 fn an_option_for_another_kind_is_refused_rather_than_ignored() {
431 let error = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK} chat=1")).unwrap_err();
432 assert!(error.contains("telegram"), "{error}");
433 let error = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK} secret=1")).unwrap_err();
434 assert!(error.contains("json"), "{error}");
435 }
436
437 #[test]
438 fn mistakes_are_refused_with_the_variable_named() {
439 for (value, expected) in [
440 ("", "empty"),
441 ("discord https://x.example", "unknown kind"),
442 ("slack", "no target"),
443 ("slack ftp://x.example", "http(s)"),
444 ("slack https://", "http(s)"),
445 (&format!("slack {HOOK} events=alert.exploded"), "unknown event"),
446 (&format!("slack {HOOK} events=test"), "unknown event"),
447 (&format!("slack {HOOK} events="), "lists nothing"),
448 (&format!("slack {HOOK} events=day.closed events=alert.raised"), "twice"),
449 (&format!("slack {HOOK} colour=red"), "unknown option"),
450 (&format!("slack {HOOK} department=\"Design"), "never closed"),
451 (&format!("slack {HOOK} department="), "empty"),
452 ] {
453 let error = Destination::parse("KASL_WEBHOOK_TEAM", value).unwrap_err();
454 assert!(error.starts_with("KASL_WEBHOOK_TEAM:"), "the variable is named: {error}");
455 assert!(error.contains(expected), "`{value}` should say {expected}: {error}");
456 }
457
458 let error = Destination::parse("KASL_WEBHOOK_", &format!("slack {HOOK}")).unwrap_err();
459 assert!(error.contains("<NAME>"), "{error}");
460 let error = Destination::parse("KASL_WEBHOOK_team", &format!("slack {HOOK}")).unwrap_err();
461 assert!(error.contains("capital"), "{error}");
462 }
463
464 #[test]
465 fn no_error_repeats_the_credential() {
466 for value in [
470 format!("slack {HOOK} stray-SECRETSECRETSECRETSECRET"),
471 format!("slack {HOOK} events=SECRETSECRETSECRETSECRET"),
472 format!("{HOOK}SECRETSECRETSECRETSECRET slack"),
473 format!("slack {HOOK} SECRETSECRETSECRETSECRET=1"),
474 "telegram 99:SECRETSECRETSECRETSECRET".to_string(),
475 "json https://x.example/SECRETSECRETSECRETSECRET".to_string(),
476 ] {
477 let error = Destination::parse("KASL_WEBHOOK_TEAM", &value).unwrap_err();
478 assert!(!error.contains("SECRETSECRETSECRETSECRET"), "the error quotes the credential: {error}");
479 }
480 }
481
482 #[test]
483 fn what_is_shown_is_the_host_and_never_the_hook() {
484 let destination = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK}")).unwrap();
485 assert_eq!(destination.shown_target(), "hooks.slack.com");
486 assert!(!format!("{destination:?}").contains("SECRET"), "Debug must not print the target");
487
488 assert_eq!(host("https://user:pw@chat.example.com:8443/hooks/x?y=1"), "chat.example.com");
489 }
490
491 #[test]
492 fn redaction_removes_every_credential_a_target_holds() {
493 let destination = Destination::parse("KASL_WEBHOOK_P", "json https://p.example/in secret=s3cr3t-value").unwrap();
494 let redacted = destination.redact("POST https://p.example/in failed, signed with s3cr3t-value");
495 assert!(!redacted.contains("https://p.example/in"), "{redacted}");
496 assert!(!redacted.contains("s3cr3t-value"), "{redacted}");
497
498 let destination = Destination::parse("KASL_WEBHOOK_T", "telegram 42:TOKEN chat=1").unwrap();
499 assert_eq!(
500 destination.redact("https://api.telegram.org/bot42:TOKEN/sendMessage"),
501 "https://api.telegram.org/bot…/sendMessage"
502 );
503 }
504}