Skip to main content

codoseo_notify/
deliver.rs

1//! Delivering an alert to one channel, and checking a channel's target when it is saved.
2//!
3//! HTTP channels (Slack, Discord, webhook) are user-supplied URLs, so every request goes through
4//! the crawler's address guard: [`check_url`] on the URL, a client whose resolver drops private
5//! and internal addresses, no redirects (a public URL that 302s to `localhost` is an error, never
6//! followed), no proxy, and a 10 second timeout.
7
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use codoseo_core::crawl::AddressPolicy;
14use codoseo_crawler::guard::{GuardError, GuardedResolver, Lookup, SystemLookup, check_url};
15use reqwest::redirect::Policy;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18use url::{Host, Url};
19
20use crate::email::{MailError, Mailer};
21use crate::message::{self, AlertMessage};
22use crate::{discord, slack, webhook};
23
24const TIMEOUT: Duration = Duration::from_secs(10);
25/// How much of an error response is kept for the channel's `last_error`.
26const BODY_EXCERPT: usize = 200;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ChannelKind {
31    Email,
32    Slack,
33    Discord,
34    Webhook,
35}
36
37impl ChannelKind {
38    /// The slug stored in `alert_channels.kind`.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            ChannelKind::Email => "email",
42            ChannelKind::Slack => "slack",
43            ChannelKind::Discord => "discord",
44            ChannelKind::Webhook => "webhook",
45        }
46    }
47
48    pub fn parse(slug: &str) -> Option<ChannelKind> {
49        match slug {
50            "email" => Some(ChannelKind::Email),
51            "slack" => Some(ChannelKind::Slack),
52            "discord" => Some(ChannelKind::Discord),
53            "webhook" => Some(ChannelKind::Webhook),
54            _ => None,
55        }
56    }
57}
58
59/// Where a channel delivers to. Slack and Discord URLs carry a token, and a webhook has a secret,
60/// so `Debug` shows hosts only.
61#[derive(Clone, PartialEq, Eq)]
62pub enum ChannelTarget {
63    Email { to: String },
64    Slack { url: Url },
65    Discord { url: Url },
66    Webhook { url: Url, secret: String },
67}
68
69impl std::fmt::Debug for ChannelTarget {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "ChannelTarget::{:?}({})", self.kind(), self.display())
72    }
73}
74
75impl ChannelTarget {
76    pub fn kind(&self) -> ChannelKind {
77        match self {
78            ChannelTarget::Email { .. } => ChannelKind::Email,
79            ChannelTarget::Slack { .. } => ChannelKind::Slack,
80            ChannelTarget::Discord { .. } => ChannelKind::Discord,
81            ChannelTarget::Webhook { .. } => ChannelKind::Webhook,
82        }
83    }
84
85    /// What may be shown on screen: the address for email, only the host for URLs (the path of a
86    /// Slack or Discord URL is its secret), never a secret.
87    pub fn display(&self) -> String {
88        match self {
89            ChannelTarget::Email { to } => to.clone(),
90            ChannelTarget::Slack { url }
91            | ChannelTarget::Discord { url }
92            | ChannelTarget::Webhook { url, .. } => url.host_str().unwrap_or("").to_owned(),
93        }
94    }
95
96    /// The JSON that is encrypted at rest: `{to}`, `{url}` or `{url, secret}`.
97    pub fn to_json(&self) -> Value {
98        match self {
99            ChannelTarget::Email { to } => json!({ "to": to }),
100            ChannelTarget::Slack { url } | ChannelTarget::Discord { url } => {
101                json!({ "url": url.as_str() })
102            }
103            ChannelTarget::Webhook { url, secret } => {
104                json!({ "url": url.as_str(), "secret": secret })
105            }
106        }
107    }
108
109    /// Reverses [`ChannelTarget::to_json`].
110    pub fn from_json(kind: ChannelKind, value: &Value) -> Result<ChannelTarget, TargetError> {
111        let field = |name: &str| {
112            value
113                .get(name)
114                .and_then(Value::as_str)
115                .filter(|s| !s.is_empty())
116                .ok_or_else(|| TargetError::Invalid(format!("the target has no `{name}`")))
117        };
118        let url = || {
119            Url::parse(field("url")?)
120                .map_err(|e| TargetError::Invalid(format!("the target URL is invalid: {e}")))
121        };
122        Ok(match kind {
123            ChannelKind::Email => ChannelTarget::Email {
124                to: field("to")?.to_owned(),
125            },
126            ChannelKind::Slack => ChannelTarget::Slack { url: url()? },
127            ChannelKind::Discord => ChannelTarget::Discord { url: url()? },
128            ChannelKind::Webhook => ChannelTarget::Webhook {
129                url: url()?,
130                secret: field("secret")?.to_owned(),
131            },
132        })
133    }
134}
135
136#[derive(Debug, thiserror::Error)]
137pub enum TargetError {
138    #[error("{0}")]
139    Invalid(String),
140    #[error("that address is not allowed: {0}")]
141    Blocked(#[from] GuardError),
142}
143
144/// The synchronous part of save-time validation: the URL's shape for the channel kind, and the
145/// guard's check of IP literals. Host names are resolved by [`GuardedHttp::validate_target`].
146fn check_shape(kind: ChannelKind, url: &str, policy: AddressPolicy) -> Result<Url, TargetError> {
147    let bad = |msg: &str| TargetError::Invalid(msg.to_owned());
148    let url = Url::parse(url.trim()).map_err(|_| bad("that doesn't look like a web address"))?;
149    let has_host = |hosts: &[&str]| url.host_str().is_some_and(|h| hosts.contains(&h));
150    match kind {
151        ChannelKind::Email => return Err(bad("email channels take an address, not a URL")),
152        ChannelKind::Slack => {
153            if url.scheme() != "https" || !has_host(&["hooks.slack.com"]) || url.path().len() < 2 {
154                return Err(bad(
155                    "a Slack webhook looks like https://hooks.slack.com/services/...",
156                ));
157            }
158        }
159        ChannelKind::Discord => {
160            if url.scheme() != "https"
161                || !has_host(&["discord.com", "discordapp.com"])
162                || !url.path().starts_with("/api/webhooks/")
163            {
164                return Err(bad(
165                    "a Discord webhook looks like https://discord.com/api/webhooks/...",
166                ));
167            }
168        }
169        ChannelKind::Webhook => {
170            let scheme_ok = url.scheme() == "https"
171                || (url.scheme() == "http" && policy == AddressPolicy::AllowPrivate);
172            if !scheme_ok || url.host_str().is_none() {
173                return Err(bad("a webhook URL must start with https://"));
174            }
175        }
176    }
177    check_url(&url, policy)?;
178    Ok(url)
179}
180
181#[derive(Debug, thiserror::Error)]
182pub enum DeliveryError {
183    /// The address guard refused the URL; nothing was sent.
184    #[error("refused: {0}")]
185    Blocked(#[from] GuardError),
186    /// The request could not be made (DNS refused by the guard, connection, timeout, ...).
187    #[error("request failed: {0}")]
188    Request(String),
189    /// The server answered something other than 2xx (a redirect counts: it is not followed).
190    #[error("the server answered {status}: {body}")]
191    Status { status: u16, body: String },
192    #[error(transparent)]
193    Mail(#[from] MailError),
194}
195
196type HostCheck =
197    dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<(), GuardError>> + Send>> + Send + Sync;
198
199/// The HTTP side of delivery: a client plus the address policy it was built for, so the two can't
200/// drift apart. Build it once per process and keep it (the job context, the web settings page).
201///
202/// The client uses the guarded DNS resolver under `Public` (private and internal addresses are
203/// dropped from answers), never follows redirects, ignores proxy settings and times out after
204/// 10 seconds. `AllowPrivate` (self-hosted) resolves normally.
205#[derive(Clone)]
206pub struct GuardedHttp {
207    client: reqwest::Client,
208    policy: AddressPolicy,
209    host_check: Arc<HostCheck>,
210}
211
212impl GuardedHttp {
213    /// Resolves names through the system resolver.
214    pub fn new(policy: AddressPolicy) -> Result<GuardedHttp, reqwest::Error> {
215        GuardedHttp::with_lookup(policy, SystemLookup)
216    }
217
218    /// [`GuardedHttp::new`] with another resolver (tests resolve names to chosen addresses).
219    pub fn with_lookup<L: Lookup>(
220        policy: AddressPolicy,
221        lookup: L,
222    ) -> Result<GuardedHttp, reqwest::Error> {
223        let resolver = Arc::new(GuardedResolver::new(lookup));
224        let mut builder = reqwest::Client::builder()
225            .redirect(Policy::none())
226            .timeout(TIMEOUT)
227            .user_agent("CodoSEO-Notify")
228            .no_proxy();
229        if policy == AddressPolicy::Public {
230            builder = builder.dns_resolver(Arc::clone(&resolver));
231        }
232        let checker = Arc::clone(&resolver);
233        let host_check: Arc<HostCheck> = Arc::new(move |host: String| {
234            let checker = Arc::clone(&checker);
235            Box::pin(async move { checker.lookup_checked(&host).await.map(|_| ()) })
236        });
237        Ok(GuardedHttp {
238            client: builder.build()?,
239            policy,
240            host_check,
241        })
242    }
243
244    pub fn policy(&self) -> AddressPolicy {
245        self.policy
246    }
247
248    /// Checks a URL a user wants to save as a channel target: Slack must be
249    /// `https://hooks.slack.com/...`, Discord `https://discord.com/api/webhooks/...` (or
250    /// `discordapp.com`), a webhook any `https` URL (plain `http` only under `AllowPrivate`).
251    /// Then the address guard: IP literals are checked, and under `Public` a host name is
252    /// resolved and refused when it only resolves to private or internal addresses (or doesn't
253    /// resolve at all). The same guard runs again at delivery.
254    pub async fn validate_target(&self, kind: ChannelKind, url: &str) -> Result<Url, TargetError> {
255        let url = check_shape(kind, url, self.policy)?;
256        if self.policy == AddressPolicy::Public
257            && let Some(Host::Domain(host)) = url.host()
258        {
259            (self.host_check)(host.to_owned()).await?;
260        }
261        Ok(url)
262    }
263}
264
265fn unix_now() -> u64 {
266    SystemTime::now()
267        .duration_since(UNIX_EPOCH)
268        .map_or(0, |d| d.as_secs())
269}
270
271/// Delivers `msg` to one channel. HTTP channels check the URL with the address guard first, then
272/// post through `http`'s guarded client.
273pub async fn deliver(
274    http: &GuardedHttp,
275    mailer: &Mailer,
276    target: &ChannelTarget,
277    msg: &AlertMessage,
278) -> Result<(), DeliveryError> {
279    match target {
280        ChannelTarget::Email { to } => Ok(mailer.send(message::email(msg, to)).await?),
281        ChannelTarget::Slack { url } => post(http, url, &slack::payload(msg), None).await,
282        ChannelTarget::Discord { url } => post(http, url, &discord::payload(msg), None).await,
283        ChannelTarget::Webhook { url, secret } => {
284            post(http, url, &webhook::payload(msg), Some(secret)).await
285        }
286    }
287}
288
289async fn post(
290    http: &GuardedHttp,
291    url: &Url,
292    payload: &Value,
293    sign_with: Option<&str>,
294) -> Result<(), DeliveryError> {
295    check_url(url, http.policy)?;
296    let body = serde_json::to_vec(payload).expect("a JSON value always serializes");
297    let mut request = http
298        .client
299        .post(url.clone())
300        .header(reqwest::header::CONTENT_TYPE, "application/json");
301    if let Some(secret) = sign_with {
302        let timestamp = unix_now();
303        request = request
304            .header(webhook::TIMESTAMP_HEADER, timestamp.to_string())
305            .header(
306                webhook::SIGNATURE_HEADER,
307                webhook::sign(secret, timestamp, &body),
308            );
309    }
310    // The error text must not carry the URL: Slack and Discord URLs hold their token.
311    let mut response = request
312        .body(body)
313        .send()
314        .await
315        .map_err(|e| DeliveryError::Request(e.without_url().to_string()))?;
316    let status = response.status();
317    if status.is_success() {
318        return Ok(());
319    }
320    let excerpt = match response.chunk().await {
321        Ok(Some(chunk)) => String::from_utf8_lossy(&chunk).into_owned(),
322        _ => String::new(),
323    };
324    Err(DeliveryError::Status {
325        status: status.as_u16(),
326        body: message::truncate(&message::one_line(&excerpt), BODY_EXCERPT),
327    })
328}