Skip to main content

isb_apps/notify/
provider.rs

1//! What each kind of channel sends: the HTTP request (or mail) for one
2//! message, built from the channel's settings and its secrets' values.
3
4use 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/// A channel's destination. Every URL, token and password is an org secret,
11/// named here and read at send time.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
14pub enum Provider {
15    /// A JSON POST to any http(s) URL, signed when `signing_secret` is set.
16    Webhook {
17        url_secret: String,
18        #[serde(default, skip_serializing_if = "Option::is_none")]
19        signing_secret: Option<String>,
20    },
21    /// A Slack incoming webhook (`https://hooks.slack.com/...`).
22    Slack { url_secret: String },
23    /// A Discord webhook (`https://discord.com/api/webhooks/...`).
24    Discord { url_secret: String },
25    /// A Telegram bot message to one chat.
26    Telegram {
27        token_secret: String,
28        chat_id: String,
29    },
30    /// Mail through an SMTP submission server.
31    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    /// The secrets this provider reads.
58    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    /// Check the settings that are not secrets.
75    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/// One thing to tell a channel about.
141#[derive(Debug, Clone, Serialize)]
142pub struct Message {
143    /// The delivery's id, for receivers that deduplicate.
144    pub id: String,
145    pub org: String,
146    pub kind: String,
147    pub level: String,
148    /// The stack's own name (not qualified).
149    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    /// What the event's producer adds: for `monitor.*`, the monitor, URL,
158    /// HTTP status, latency, error, downtime and a link to its page.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub details: Option<serde_json::Value>,
161    /// Unix milliseconds of the event.
162    pub at: u64,
163    /// The event's number on the daemon's feed (0 for a test).
164    pub seq: u64,
165    pub test: bool,
166}
167
168impl Message {
169    /// `acme/shop-production/web`.
170    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    /// One line: `[isb] deploy.failed acme/shop-production/web`.
180    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
190/// Validate a Slack, Discord or webhook URL read from a secret.
191fn 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
224/// Check a URL secret's value when a channel is saved (the value is never
225/// echoed).
226pub fn check_url_value(p: &Provider, url: &str) -> Result<(), String> {
227    checked_url(p, url).map(|_| ()).map_err(|e| e.message)
228}
229
230/// A Telegram bot token: `<digits>:<url-safe chars>`, so it cannot change
231/// the request path.
232fn 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
249/// Where Telegram's Bot API lives.
250pub const TELEGRAM_API: &str = "https://api.telegram.org";
251
252/// Slack's mrkdwn treats &, < and > specially.
253fn slack_escape(s: &str) -> String {
254    s.replace('&', "&amp;")
255        .replace('<', "&lt;")
256        .replace('>', "&gt;")
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
269/// The plain-text body every human-facing provider sends.
270pub 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
282/// The details people want at a glance (a monitor's latency, its page),
283/// one `\nkey: value` line each; empty without details.
284pub 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
305/// The HTTP request for an HTTP provider. `secret` reads a named secret's
306/// value. Email is not HTTP: see [`super::smtp`].
307pub 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            // The title in bold, then the rest of the text.
346            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                // Never ping anyone from event text.
362                "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        // Unsigned without a signing secret.
459        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("&lt;boom&gt; &amp; 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        // Discord's URL is not Slack's.
496        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}