Skip to main content

faucet_cli/notify/
channels.rs

1//! HTTP delivery for each channel (#280).
2//!
3//! Thin I/O shims: take a rendered JSON body + the channel config, POST it, and
4//! map the HTTP outcome to `Result<(), String>` (the `Err` string is logged,
5//! never surfaced to the pipeline). All retry / timeout / dedupe policy lives in
6//! [`crate::notify::dispatch`]; these functions do exactly one request each.
7
8use super::render::{self, PdAction};
9use super::spec::{PagerdutyConfig, SlackConfig, WebhookConfig};
10use crate::notify::event::NotifyEvent;
11use hmac::{Hmac, Mac};
12use reqwest::Client;
13use serde_json::Value;
14use sha2::Sha256;
15
16/// Default PagerDuty Events API v2 endpoint (global).
17pub const PAGERDUTY_ENDPOINT: &str = "https://events.pagerduty.com/v2/enqueue";
18
19/// Deliver a Slack message.
20pub async fn send_slack(
21    client: &Client,
22    cfg: &SlackConfig,
23    event: &NotifyEvent,
24) -> Result<(), String> {
25    let body = render::slack(cfg, event);
26    post_json(client, &cfg.webhook_url, &body, &[]).await
27}
28
29/// Deliver a PagerDuty trigger or resolve.
30pub async fn send_pagerduty(
31    client: &Client,
32    cfg: &PagerdutyConfig,
33    event: &NotifyEvent,
34    action: PdAction,
35    dedup_key: &str,
36) -> Result<(), String> {
37    let body = render::pagerduty(cfg, event, action, dedup_key);
38    let url = cfg.endpoint.as_deref().unwrap_or(PAGERDUTY_ENDPOINT);
39    post_json(client, url, &body, &[]).await
40}
41
42/// Deliver a generic webhook, optionally HMAC-signed.
43pub async fn send_webhook(
44    client: &Client,
45    cfg: &WebhookConfig,
46    event: &NotifyEvent,
47) -> Result<(), String> {
48    let body = render::webhook(cfg, event);
49    let raw = serde_json::to_vec(&body).map_err(|e| format!("serializing webhook body: {e}"))?;
50
51    let mut headers: Vec<(String, String)> = cfg
52        .headers
53        .iter()
54        .map(|(k, v)| (k.clone(), v.clone()))
55        .collect();
56    if let Some(secret) = &cfg.hmac_secret {
57        headers.push((
58            cfg.signature_header.clone(),
59            hmac_sha256_hex(secret.as_bytes(), &raw),
60        ));
61    }
62
63    let method = reqwest::Method::from_bytes(cfg.method.as_bytes())
64        .map_err(|_| format!("invalid HTTP method `{}`", cfg.method))?;
65    let mut req = client
66        .request(method, &cfg.url)
67        .header(reqwest::header::CONTENT_TYPE, "application/json")
68        .body(raw);
69    for (k, v) in &headers {
70        req = req.header(k, v);
71    }
72    let resp = req
73        .send()
74        .await
75        .map_err(|e| format!("request failed: {e}"))?;
76    check_status(resp).await
77}
78
79/// POST a JSON body with optional extra headers.
80async fn post_json(
81    client: &Client,
82    url: &str,
83    body: &Value,
84    headers: &[(String, String)],
85) -> Result<(), String> {
86    let mut req = client.post(url).json(body);
87    for (k, v) in headers {
88        req = req.header(k, v);
89    }
90    let resp = req
91        .send()
92        .await
93        .map_err(|e| format!("request failed: {e}"))?;
94    check_status(resp).await
95}
96
97/// A 2xx is success; anything else is an error carrying the status + a short
98/// body excerpt (helps diagnose a bad Slack/PD token without leaking much).
99async fn check_status(resp: reqwest::Response) -> Result<(), String> {
100    let status = resp.status();
101    if status.is_success() {
102        return Ok(());
103    }
104    let body = resp.text().await.unwrap_or_default();
105    let excerpt: String = body.chars().take(200).collect();
106    Err(format!("HTTP {status}: {excerpt}"))
107}
108
109/// Lowercase-hex HMAC-SHA256 of `data` under `key`.
110fn hmac_sha256_hex(key: &[u8], data: &[u8]) -> String {
111    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
112    mac.update(data);
113    let bytes = mac.finalize().into_bytes();
114    let mut out = String::with_capacity(bytes.len() * 2);
115    for b in bytes {
116        use std::fmt::Write;
117        let _ = write!(out, "{b:02x}");
118    }
119    out
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn hmac_matches_known_vector() {
128        // RFC 4231 test case 1: key = 0x0b*20, data = "Hi There".
129        let key = [0x0bu8; 20];
130        let got = hmac_sha256_hex(&key, b"Hi There");
131        assert_eq!(
132            got,
133            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
134        );
135    }
136
137    #[test]
138    fn hmac_is_deterministic() {
139        let a = hmac_sha256_hex(b"secret", b"payload");
140        let b = hmac_sha256_hex(b"secret", b"payload");
141        assert_eq!(a, b);
142        assert_ne!(a, hmac_sha256_hex(b"secret", b"other"));
143        assert_eq!(a.len(), 64); // 32 bytes hex
144    }
145}