1use serde::{Deserialize, Serialize};
5use serde_json::json;
6
7use super::net::{Request, SendError, Target, hmac_sha256_hex, parse_url};
8use super::smtp::SmtpTls;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
14pub enum Provider {
15 Webhook {
17 url_secret: String,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 signing_secret: Option<String>,
20 },
21 Slack { url_secret: String },
23 Discord { url_secret: String },
25 Telegram {
27 token_secret: String,
28 chat_id: String,
29 },
30 Email {
32 host: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 port: Option<u16>,
35 #[serde(default)]
36 tls: SmtpTls,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 username: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 password_secret: Option<String>,
41 from: String,
42 to: Vec<String>,
43 },
44}
45
46impl Provider {
47 pub fn kind(&self) -> &'static str {
48 match self {
49 Provider::Webhook { .. } => "webhook",
50 Provider::Slack { .. } => "slack",
51 Provider::Discord { .. } => "discord",
52 Provider::Telegram { .. } => "telegram",
53 Provider::Email { .. } => "email",
54 }
55 }
56
57 pub fn secrets(&self) -> Vec<&str> {
59 match self {
60 Provider::Webhook {
61 url_secret,
62 signing_secret,
63 } => std::iter::once(url_secret.as_str())
64 .chain(signing_secret.as_deref())
65 .collect(),
66 Provider::Slack { url_secret } | Provider::Discord { url_secret } => vec![url_secret],
67 Provider::Telegram { token_secret, .. } => vec![token_secret],
68 Provider::Email {
69 password_secret, ..
70 } => password_secret.as_deref().into_iter().collect(),
71 }
72 }
73
74 pub fn validate(&self) -> Result<(), String> {
76 for s in self.secrets() {
77 crate::secrets::validate_name(s).map_err(|e| e.to_string())?;
78 }
79 match self {
80 Provider::Telegram { chat_id, .. } => {
81 let ok = chat_id
82 .strip_prefix('-')
83 .unwrap_or(chat_id)
84 .chars()
85 .all(|c| c.is_ascii_digit())
86 && !chat_id.trim_start_matches('-').is_empty()
87 || chat_id.strip_prefix('@').is_some_and(|n| {
88 !n.is_empty()
89 && n.len() <= 64
90 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
91 });
92 if !ok {
93 return Err(format!(
94 "chat_id {chat_id:?}: a numeric chat id or an @channel name"
95 ));
96 }
97 }
98 Provider::Email {
99 host,
100 from,
101 to,
102 username,
103 password_secret,
104 port,
105 ..
106 } => {
107 if host.is_empty()
108 || !host
109 .chars()
110 .all(|c| c.is_ascii_alphanumeric() || ".-_:".contains(c))
111 {
112 return Err(format!("host {host:?} is not a host name"));
113 }
114 if *port == Some(0) {
115 return Err("port 0".into());
116 }
117 super::smtp::check_address(from)?;
118 if to.is_empty() || to.len() > 20 {
119 return Err("email needs 1 to 20 recipients in `to`".into());
120 }
121 for t in to {
122 super::smtp::check_address(t)?;
123 }
124 if username.is_some() != password_secret.is_some() {
125 return Err("email: username and password_secret go together".into());
126 }
127 if username
128 .as_deref()
129 .is_some_and(|u| u.chars().any(|c| c.is_control()))
130 {
131 return Err("email: a control character in username".into());
132 }
133 }
134 _ => {}
135 }
136 Ok(())
137 }
138}
139
140#[derive(Debug, Clone, Serialize)]
142pub struct Message {
143 pub id: String,
145 pub org: String,
146 pub kind: String,
147 pub level: String,
148 pub stack: String,
150 #[serde(skip_serializing_if = "String::is_empty")]
151 pub service: String,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub project: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub instance: Option<String>,
156 pub message: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
160 pub details: Option<serde_json::Value>,
161 pub at: u64,
163 pub seq: u64,
165 pub test: bool,
166}
167
168impl Message {
169 pub fn subject(&self) -> String {
171 let mut s = format!("{}/{}", self.org, self.stack);
172 if !self.service.is_empty() {
173 s.push('/');
174 s.push_str(&self.service);
175 }
176 s
177 }
178
179 pub fn title(&self) -> String {
181 format!(
182 "[isb]{} {} {}",
183 if self.test { " test:" } else { "" },
184 self.kind,
185 self.subject()
186 )
187 }
188}
189
190fn checked_url(p: &Provider, url: &str) -> Result<Target, SendError> {
192 let t = parse_url(url).map_err(|e| SendError::permanent(format!("{} URL: {e}", p.kind())))?;
193 let want = |hosts: &[&str], prefix: &str| -> Result<(), SendError> {
194 if !t.https
195 || !hosts.contains(&t.host.as_str())
196 || t.port != 443
197 || !t.path.starts_with(prefix)
198 {
199 return Err(SendError::permanent(format!(
200 "a {} URL must be https://{}{prefix}...; this one is for {}",
201 p.kind(),
202 hosts[0],
203 t.host
204 )));
205 }
206 Ok(())
207 };
208 match p {
209 Provider::Slack { .. } => want(&["hooks.slack.com"], "/services/")?,
210 Provider::Discord { .. } => want(
211 &[
212 "discord.com",
213 "discordapp.com",
214 "ptb.discord.com",
215 "canary.discord.com",
216 ],
217 "/api/webhooks/",
218 )?,
219 _ => {}
220 }
221 Ok(t)
222}
223
224pub fn check_url_value(p: &Provider, url: &str) -> Result<(), String> {
227 checked_url(p, url).map(|_| ()).map_err(|e| e.message)
228}
229
230fn check_bot_token(t: &str) -> Result<(), SendError> {
233 let ok = t.split_once(':').is_some_and(|(id, k)| {
234 !id.is_empty()
235 && id.chars().all(|c| c.is_ascii_digit())
236 && !k.is_empty()
237 && k.chars()
238 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
239 });
240 if ok {
241 Ok(())
242 } else {
243 Err(SendError::permanent(
244 "the Telegram bot token is malformed (want 123456:ABC-...)",
245 ))
246 }
247}
248
249pub const TELEGRAM_API: &str = "https://api.telegram.org";
251
252fn slack_escape(s: &str) -> String {
254 s.replace('&', "&")
255 .replace('<', "<")
256 .replace('>', ">")
257}
258
259fn cap(s: &str, n: usize) -> String {
260 if s.chars().count() <= n {
261 s.to_string()
262 } else {
263 let mut o: String = s.chars().take(n.saturating_sub(1)).collect();
264 o.push('…');
265 o
266 }
267}
268
269pub fn text(m: &Message) -> String {
271 let mut t = format!("{}\n{}", m.title(), m.message);
272 if let Some(p) = &m.project {
273 t.push_str(&format!("\nproject: {p}"));
274 }
275 if let Some(i) = &m.instance {
276 t.push_str(&format!("\ninstance: {i}"));
277 }
278 t.push_str(&detail_lines(m));
279 t
280}
281
282pub fn detail_lines(m: &Message) -> String {
285 let Some(d) = &m.details else {
286 return String::new();
287 };
288 let mut t = String::new();
289 if let Some(l) = d.get("latency_ms").and_then(|v| v.as_u64()) {
290 t.push_str(&format!("\nlatency: {l} ms"));
291 }
292 if let Some(v) = d
293 .get("via")
294 .and_then(|v| v.as_str())
295 .filter(|v| *v != "public")
296 {
297 t.push_str(&format!("\nchecked: {v}"));
298 }
299 if let Some(l) = d.get("link").and_then(|v| v.as_str()) {
300 t.push_str(&format!("\n{l}"));
301 }
302 t
303}
304
305pub fn request(
308 p: &Provider,
309 m: &Message,
310 secret: &dyn Fn(&str) -> Result<String, SendError>,
311) -> Result<Request, SendError> {
312 let json_headers = |extra: Vec<(String, String)>| {
313 let mut h = vec![("Content-Type".to_string(), "application/json".to_string())];
314 h.extend(extra);
315 h
316 };
317 match p {
318 Provider::Webhook {
319 url_secret,
320 signing_secret,
321 } => {
322 let url = secret(url_secret)?;
323 checked_url(p, &url)?;
324 let body = serde_json::to_vec(m).expect("a message serializes");
325 let mut extra = vec![
326 ("X-Isb-Event".to_string(), m.kind.clone()),
327 ("X-Isb-Delivery".to_string(), m.id.clone()),
328 ];
329 if let Some(s) = signing_secret {
330 let key = secret(s)?;
331 extra.push((
332 "X-Isb-Signature".to_string(),
333 format!("sha256={}", hmac_sha256_hex(key.as_bytes(), &body)),
334 ));
335 }
336 Ok(Request {
337 url,
338 headers: json_headers(extra),
339 body,
340 })
341 }
342 Provider::Slack { url_secret } => {
343 let url = secret(url_secret)?;
344 checked_url(p, &url)?;
345 let full = text(m);
347 let rest = full.split_once('\n').map(|x| x.1).unwrap_or_default();
348 let t = format!("*{}*\n{}", slack_escape(&m.title()), slack_escape(rest));
349 let body = json!({"text": cap(&t, 3000)});
350 Ok(Request {
351 url,
352 headers: json_headers(vec![]),
353 body: serde_json::to_vec(&body).expect("json"),
354 })
355 }
356 Provider::Discord { url_secret } => {
357 let url = secret(url_secret)?;
358 checked_url(p, &url)?;
359 let body = json!({
360 "content": cap(&text(m), 2000),
361 "allowed_mentions": {"parse": []},
363 });
364 Ok(Request {
365 url,
366 headers: json_headers(vec![]),
367 body: serde_json::to_vec(&body).expect("json"),
368 })
369 }
370 Provider::Telegram {
371 token_secret,
372 chat_id,
373 } => {
374 let token = secret(token_secret)?;
375 check_bot_token(&token)?;
376 let body = json!({
377 "chat_id": chat_id,
378 "text": cap(&text(m), 4096),
379 "disable_web_page_preview": true,
380 });
381 Ok(Request {
382 url: format!("{TELEGRAM_API}/bot{token}/sendMessage"),
383 headers: json_headers(vec![]),
384 body: serde_json::to_vec(&body).expect("json"),
385 })
386 }
387 Provider::Email { .. } => Err(SendError::permanent("email is not sent over HTTP")),
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use serde_json::Value;
395
396 fn msg() -> Message {
397 Message {
398 id: "d1".into(),
399 org: "acme".into(),
400 kind: "deploy.failed".into(),
401 level: "error".into(),
402 stack: "shop-production".into(),
403 service: "web".into(),
404 project: Some("shop".into()),
405 instance: None,
406 message: "app web: deployment 3 failed: <boom> & more".into(),
407 details: None,
408 at: 1,
409 seq: 9,
410 test: false,
411 }
412 }
413
414 fn secrets(name: &str) -> Result<String, SendError> {
415 Ok(match name {
416 "HOOK" => "https://example.com/hook".into(),
417 "SIGN" => "s3cret".into(),
418 "SLACK" => "https://hooks.slack.com/services/T0/B0/xyz".into(),
419 "DISCORD" => "https://discord.com/api/webhooks/1/abc".into(),
420 "TG" => "123456:ABC-def_1".into(),
421 "BADSLACK" => "https://evil.example/services/x".into(),
422 _ => return Err(SendError::permanent(format!("no secret {name}"))),
423 })
424 }
425
426 fn body(r: &Request) -> Value {
427 serde_json::from_slice(&r.body).unwrap()
428 }
429
430 #[test]
431 fn webhook_is_signed_json() {
432 let p = Provider::Webhook {
433 url_secret: "HOOK".into(),
434 signing_secret: Some("SIGN".into()),
435 };
436 let r = request(&p, &msg(), &secrets).unwrap();
437 let b = body(&r);
438 assert_eq!(b["kind"], "deploy.failed");
439 assert_eq!(b["org"], "acme");
440 assert_eq!(b["service"], "web");
441 assert_eq!(b["project"], "shop");
442 let sig = r
443 .headers
444 .iter()
445 .find(|(k, _)| k == "X-Isb-Signature")
446 .unwrap()
447 .1
448 .clone();
449 assert_eq!(
450 sig,
451 format!("sha256={}", hmac_sha256_hex(b"s3cret", &r.body))
452 );
453 assert!(
454 r.headers
455 .iter()
456 .any(|(k, v)| k == "X-Isb-Event" && v == "deploy.failed")
457 );
458 let p = Provider::Webhook {
460 url_secret: "HOOK".into(),
461 signing_secret: None,
462 };
463 let r = request(&p, &msg(), &secrets).unwrap();
464 assert!(!r.headers.iter().any(|(k, _)| k == "X-Isb-Signature"));
465 }
466
467 #[test]
468 fn slack_escapes_and_checks_its_host() {
469 let r = request(
470 &Provider::Slack {
471 url_secret: "SLACK".into(),
472 },
473 &msg(),
474 &secrets,
475 )
476 .unwrap();
477 let t = body(&r)["text"].as_str().unwrap().to_string();
478 assert!(
479 t.starts_with("*[isb] deploy.failed acme/shop-production/web*"),
480 "{t}"
481 );
482 assert!(t.contains("<boom> & more"), "{t}");
483 let e = request(
484 &Provider::Slack {
485 url_secret: "BADSLACK".into(),
486 },
487 &msg(),
488 &secrets,
489 )
490 .unwrap_err();
491 assert!(
492 e.message.contains("hooks.slack.com") && !e.message.contains("/services/x"),
493 "{e}"
494 );
495 let e = request(
497 &Provider::Slack {
498 url_secret: "DISCORD".into(),
499 },
500 &msg(),
501 &secrets,
502 );
503 assert!(e.is_err());
504 }
505
506 #[test]
507 fn discord_never_pings() {
508 let r = request(
509 &Provider::Discord {
510 url_secret: "DISCORD".into(),
511 },
512 &msg(),
513 &secrets,
514 )
515 .unwrap();
516 let b = body(&r);
517 assert_eq!(b["allowed_mentions"]["parse"], serde_json::json!([]));
518 assert!(
519 b["content"]
520 .as_str()
521 .unwrap()
522 .contains("deployment 3 failed")
523 );
524 assert!(
525 request(
526 &Provider::Discord {
527 url_secret: "SLACK".into()
528 },
529 &msg(),
530 &secrets
531 )
532 .is_err()
533 );
534 let mut long = msg();
535 long.message = "x".repeat(5000);
536 let r = request(
537 &Provider::Discord {
538 url_secret: "DISCORD".into(),
539 },
540 &long,
541 &secrets,
542 )
543 .unwrap();
544 assert_eq!(body(&r)["content"].as_str().unwrap().chars().count(), 2000);
545 }
546
547 #[test]
548 fn telegram_puts_the_token_in_the_path_only_when_well_formed() {
549 let p = Provider::Telegram {
550 token_secret: "TG".into(),
551 chat_id: "-100123".into(),
552 };
553 p.validate().unwrap();
554 let r = request(&p, &msg(), &secrets).unwrap();
555 assert_eq!(
556 r.url,
557 "https://api.telegram.org/bot123456:ABC-def_1/sendMessage"
558 );
559 assert_eq!(body(&r)["chat_id"], "-100123");
560 let bad = Provider::Telegram {
561 token_secret: "HOOK".into(),
562 chat_id: "1".into(),
563 };
564 let e = request(&bad, &msg(), &secrets).unwrap_err();
565 assert!(!e.message.contains("example.com"), "{e}");
566 for c in ["@my_channel", "42", "-1001"] {
567 assert!(
568 Provider::Telegram {
569 token_secret: "TG".into(),
570 chat_id: c.into()
571 }
572 .validate()
573 .is_ok(),
574 "{c}"
575 );
576 }
577 for c in ["", "-", "abc", "@", "@a/b", "1 2"] {
578 assert!(
579 Provider::Telegram {
580 token_secret: "TG".into(),
581 chat_id: c.into()
582 }
583 .validate()
584 .is_err(),
585 "{c}"
586 );
587 }
588 }
589
590 #[test]
591 fn email_settings() {
592 let ok = Provider::Email {
593 host: "smtp.example.com".into(),
594 port: None,
595 tls: SmtpTls::Starttls,
596 username: Some("u".into()),
597 password_secret: Some("SMTP_PW".into()),
598 from: "isb@example.com".into(),
599 to: vec!["ops@example.com".into()],
600 };
601 ok.validate().unwrap();
602 let v = serde_json::to_value(&ok).unwrap();
603 assert_eq!(v["type"], "email");
604 assert_eq!(v["tls"], "starttls");
605 let mut bad = ok.clone();
606 if let Provider::Email { to, .. } = &mut bad {
607 to.push("x\r\nBcc: y@z".into());
608 }
609 assert!(bad.validate().is_err());
610 let mut bad = ok;
611 if let Provider::Email { username, .. } = &mut bad {
612 *username = None;
613 }
614 assert!(bad.validate().is_err());
615 }
616}