Skip to main content

kasl_server/webhooks/
destination.rs

1//! Where events go: destinations read from the deployment's environment.
2//!
3//! One variable per destination, `KASL_WEBHOOK_<NAME>`, and the name is the
4//! label the rest of the server uses - in the delivery log, on the screen, in
5//! the privacy manifest. The address itself never leaves this module except
6//! into the request that uses it (ADR 0019).
7//!
8//! The value is a kind, a target, and options:
9//!
10//! ```text
11//! KASL_WEBHOOK_TEAM=slack https://hooks.slack.com/services/T0/B0/XXXX
12//! KASL_WEBHOOK_DESIGN=mattermost https://chat.example.com/hooks/xxx department=Design
13//! KASL_WEBHOOK_OPS=telegram 123456:ABC-DEF chat=-1001234567890 events=alert.raised,alert.resolved
14//! KASL_WEBHOOK_PAYROLL=json https://payroll.example.com/kasl secret=... events=day.closed
15//! ```
16//!
17//! A value that does not parse stops the server from starting. A destination
18//! silently dropped because of a typo is a channel that never hears about the
19//! agent that died, and nothing anywhere would say so.
20
21use std::fmt;
22
23use serde::{Deserialize, Serialize};
24
25/// The prefix every destination's variable starts with.
26pub const PREFIX: &str = "KASL_WEBHOOK_";
27
28/// Telegram's Bot API, where a `telegram` destination posts.
29const TELEGRAM_API: &str = "https://api.telegram.org";
30
31/// What a destination speaks.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Kind {
35    /// A Slack incoming webhook.
36    Slack,
37    /// A Mattermost incoming webhook. Accepts Slack's shape, but reads
38    /// Markdown rather than Slack's own markup, so the text is rendered apart.
39    Mattermost,
40    /// A Telegram chat, through a bot the operator created.
41    Telegram,
42    /// The event itself as JSON, signed - for a system of the operator's own.
43    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/// What happened. Mirrors the `webhook_event` enum.
59#[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    /// Every event a destination can subscribe to. `test` is not among them:
81    /// it is sent only when an administrator asks, to the one destination they
82    /// asked about, whatever it subscribes to.
83    pub const SUBSCRIBABLE: [Self; 4] = [Self::AlertRaised, Self::AlertAcknowledged, Self::AlertResolved, Self::DayClosed];
84
85    /// What a destination hears when it names no events: the life of an
86    /// alert, and not the days. An alert is rare and a day closes for every
87    /// person every day - a channel that opts into that should have done so
88    /// on purpose.
89    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/// Where a destination's requests go. Private, and printed by nobody: every
110/// variant carries a credential.
111#[derive(Clone, PartialEq, Eq)]
112pub(super) enum Target {
113    /// Slack and Mattermost: the hook URL is the whole credential.
114    Hook { url: String },
115    /// Telegram: a bot token and the chat it posts into. `api` is the Bot
116    /// API's address - fixed in production, pointed at a local listener by
117    /// the tests.
118    Telegram { token: String, chat: String, api: String },
119    /// A receiver of the operator's own, and the secret the body is signed
120    /// with.
121    Json { url: String, secret: String },
122}
123
124impl fmt::Debug for Target {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        // The one thing a `{:?}` in a log line must not do is print a hook.
127        f.write_str("Target(..)")
128    }
129}
130
131/// One place events are sent.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Destination {
134    /// The label: `KASL_WEBHOOK_TEAM_CHAT` is `team-chat`.
135    pub name: String,
136    pub kind: Kind,
137    pub(super) target: Target,
138    /// What it hears.
139    pub events: Vec<EventKind>,
140    /// The one department it is about, when it is about one. A channel for
141    /// the design team hears about the design team: the same boundary every
142    /// screen draws around a manager (ADR 0009).
143    pub department: Option<String>,
144}
145
146impl Destination {
147    /// Reads one destination from its variable.
148    ///
149    /// `variable` is the full name, `KASL_WEBHOOK_TEAM`. Errors name the
150    /// variable and never repeat its value - the value is a credential, and
151    /// an error message ends up in a log.
152    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        // Options that belong to one kind are refused on the others rather
207        // than ignored: `chat=` on a Slack hook is somebody who meant
208        // Telegram, and a `secret=` nobody checks is a false sense of one.
209        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                // A bot token is `<digits>:<letters>`. Checked for shape so a
222                // chat id pasted into the token's place is caught here rather
223                // than as a 404 from Telegram at the first alert.
224                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                // Required, not recommended. An unsigned body is one anybody
239                // who learns the address can forge, and a receiver that pays
240                // or pages on these cannot tell.
241                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    /// Whether this destination hears about `event`.
259    pub fn hears(&self, event: EventKind) -> bool {
260        self.events.contains(&event)
261    }
262
263    /// Where it goes, in words safe to show: the host of a hook, the chat of
264    /// a bot. Enough to tell two destinations apart and to notice one pointed
265    /// at the wrong place, and never enough to post through it.
266    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    /// Removes the credential from a piece of text before it is kept.
274    ///
275    /// The dispatcher strips URLs from its errors already; this is the second
276    /// lock, for the text nobody predicted - a proxy that echoes the request
277    /// line, a library that changes what its errors say.
278    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    /// Points a telegram destination at another Bot API. For the tests, which
291    /// cannot talk to Telegram and must not.
292    #[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
301/// The label a variable gives its destination.
302fn 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
312/// Splits on whitespace, keeping a double-quoted run together: a department
313/// is called "Customer Success" as often as it is called "Design".
314fn 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
346/// A word from the value, fit to quote in an error: whole when it is short
347/// enough to be a typo of an event or an option, cut to its first characters
348/// when it is long enough to be a credential pasted into the wrong place.
349fn 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
358/// Accepts an `https://` or `http://` address. Plain http is allowed because
359/// a Mattermost on the office network often has no certificate, and refusing
360/// it would push that operator towards something worse than a LAN hop.
361fn 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
369/// The host of an address, without scheme, port, path or credentials.
370fn 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        // A chat id where the token belongs: caught now, not as a 404 at the
417        // first alert.
418        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        // An error is logged, and a log ships to wherever logs go. The failure
467        // this guards is the helpful message that quotes the value it could
468        // not read - which, here, is a working hook.
469        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}