use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::header::{HeaderMap, HeaderName, HeaderValue};
use hyper::{Method, Request};
use tracing::info;
use url::Url;
use super::{NotifyBackend, NotifyError, NotifyEvent, render};
use crate::config::WebhookNotifyConfig;
use crate::http_client::MAX_ERROR_BODY_CHARS;
const ALLOWED_METHODS: [&str; 3] = ["POST", "PUT", "PATCH"];
pub struct WebhookNotifier {
entry: String,
url: Url,
method: Method,
headers: HeaderMap,
body_template: String,
timeout: Duration,
tls: Arc<rustls::ClientConfig>,
outbound: crate::http_client::Outbound,
env: Arc<minijinja::Environment<'static>>,
}
impl std::fmt::Debug for WebhookNotifier {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WebhookNotifier")
.field("entry", &self.entry)
.field("webhook_host", &self.url.host_str())
.field("method", &self.method.as_str())
.field(
"headers",
&self
.headers
.keys()
.map(HeaderName::as_str)
.collect::<Vec<_>>(),
)
.field("timeout", &self.timeout)
.finish()
}
}
impl WebhookNotifier {
pub fn from_config(
entry: &str,
cfg: &WebhookNotifyConfig,
env: &minijinja::Environment<'static>,
outbound: crate::http_client::Outbound,
) -> anyhow::Result<Self> {
let key = format!("notify.webhook.{entry}");
anyhow::ensure!(
!cfg.url.trim().is_empty(),
"{key} is enabled but {key}.url is empty"
);
let url: Url = cfg
.url
.parse()
.map_err(|error| anyhow::anyhow!("{key}.url is not a valid URL: {error}"))?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https"),
"{key}.url must be http:// or https://"
);
let spelled = cfg.method.trim().to_ascii_uppercase();
anyhow::ensure!(
ALLOWED_METHODS.contains(&spelled.as_str()),
"{key}.method: unknown method `{}` (expected one of {ALLOWED_METHODS:?})",
cfg.method
);
let method = Method::from_bytes(spelled.as_bytes())
.map_err(|error| anyhow::anyhow!("{key}.method is not a valid HTTP method: {error}"))?;
let headers = build_headers(&key, &cfg.headers)?;
anyhow::ensure!(
!cfg.body.trim().is_empty(),
"{key}.body is empty; a webhook with no payload delivers nothing"
);
let body_template = format!("webhook:{entry}.body");
let mut env = env.clone();
env.add_template_owned(body_template.clone(), cfg.body.clone())
.map_err(|error| anyhow::anyhow!("{key}.body is not a valid template: {error}"))?;
info!(
event = "notify_webhook_loaded",
outcome = "success",
entry = %entry,
method = %method,
webhook_host = ?url.host_str(),
);
Ok(Self {
entry: entry.to_string(),
url,
method,
headers,
body_template,
timeout: Duration::from_millis(cfg.timeout_ms),
tls: Arc::new(crate::http_client::webpki_tls_config()),
outbound,
env: Arc::new(env),
})
}
fn body_for(&self, event: &NotifyEvent) -> Result<String, NotifyError> {
let message = render(&self.env, &format!("webhook/{}.j2", event.kind()), event)?;
let template = self
.env
.get_template(&self.body_template)
.map_err(|error| {
NotifyError::permanent(format!("notify.webhook.{}.body: {error}", self.entry))
})?;
template
.render(minijinja::context! {
message,
hook => event.kind(),
..event.context()
})
.map_err(|error| {
NotifyError::permanent(format!("notify.webhook.{}.body: {error}", self.entry))
})
}
}
fn build_headers(key: &str, configured: &BTreeMap<String, String>) -> anyhow::Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
hyper::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(
hyper::header::USER_AGENT,
HeaderValue::from_static("acme-proxy"),
);
for (name, value) in configured {
let name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| {
anyhow::anyhow!("{key}.headers: `{name}` is not a header name: {error}")
})?;
let value = HeaderValue::from_str(value).map_err(|_| {
anyhow::anyhow!("{key}.headers.{name}: the value is not a valid header value")
})?;
headers.insert(name, value);
}
Ok(headers)
}
#[async_trait]
impl NotifyBackend for WebhookNotifier {
fn name(&self) -> &'static str {
"webhook"
}
async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError> {
let body = Bytes::from(self.body_for(event)?.into_bytes());
let (status, excerpt) = tokio::time::timeout(
self.timeout,
send_request(
&self.tls,
&self.outbound,
&self.method,
&self.url,
&self.headers,
body,
),
)
.await
.map_err(|_| NotifyError::new(format!("timed out after {:?}", self.timeout)))??;
if status.is_success() {
Ok(())
} else {
let detail = if excerpt.is_empty() {
format!("webhook returned {status}")
} else {
format!("webhook returned {status}: {excerpt}")
};
if retryable_status(status) {
Err(NotifyError::new(detail))
} else {
Err(NotifyError::permanent(detail))
}
}
}
}
fn retryable_status(status: hyper::StatusCode) -> bool {
status.is_server_error()
|| status == hyper::StatusCode::TOO_MANY_REQUESTS
|| status == hyper::StatusCode::REQUEST_TIMEOUT
}
async fn send_request(
tls: &Arc<rustls::ClientConfig>,
outbound: &crate::http_client::Outbound,
method: &Method,
url: &Url,
headers: &HeaderMap,
body: Bytes,
) -> Result<(hyper::StatusCode, String), NotifyError> {
let endpoint = crate::http_client::Endpoint::from_url(url).map_err(NotifyError::permanent)?;
let mut connection = outbound
.connect(&endpoint, tls)
.await
.map_err(NotifyError::new)?;
let mut builder = Request::builder()
.method(method.clone())
.uri(connection.request_target(url))
.header(hyper::header::HOST, endpoint.authority());
for (name, value) in headers {
builder = builder.header(name, value);
}
let request = builder
.body(Full::new(body))
.map_err(|error| NotifyError::permanent(format!("failed to build request: {error}")))?;
let response = connection
.send_request(request)
.await
.map_err(|error| NotifyError::new(error.to_string()))?;
let status = response.status();
let body = response
.into_body()
.collect()
.await
.map(http_body_util::Collected::to_bytes)
.unwrap_or_default();
let excerpt = String::from_utf8_lossy(&body)
.chars()
.take(MAX_ERROR_BODY_CHARS)
.collect::<String>()
.trim()
.to_string();
Ok((status, excerpt))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notify::{ChallengeFailedData, ProfileMountedData, build_environment};
use hyper_util::rt::TokioIo;
fn test_resolver() -> Arc<dyn crate::dns::Resolver> {
Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
}
fn cfg() -> WebhookNotifyConfig {
WebhookNotifyConfig {
url: "https://chat.example.com/hooks/xyz".to_string(),
..WebhookNotifyConfig::default()
}
}
fn build(cfg: &WebhookNotifyConfig) -> anyhow::Result<WebhookNotifier> {
WebhookNotifier::from_config(
"chat",
cfg,
&build_environment(""),
crate::testutil::outbound_with(test_resolver()),
)
}
fn mounted() -> NotifyEvent {
NotifyEvent::ProfileMounted(ProfileMountedData {
profile: "default".to_string(),
})
}
async fn serve_once(
status: hyper::StatusCode,
body: &'static str,
) -> (
std::net::SocketAddr,
tokio::sync::oneshot::Receiver<(String, HeaderMap, Bytes)>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let io = TokioIo::new(stream);
let tx = std::sync::Mutex::new(Some(tx));
let service = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
let tx = tx.lock().unwrap().take();
async move {
let method = req.method().to_string();
let headers = req.headers().clone();
let received = BodyExt::collect(req.into_body()).await.unwrap().to_bytes();
if let Some(tx) = tx {
let _ = tx.send((method, headers, received));
}
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.status(status)
.body(Full::new(Bytes::from_static(body.as_bytes())))
.unwrap(),
)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, service)
.await;
});
(addr, rx)
}
#[test]
fn an_unusable_entry_is_a_startup_error() {
let cases: Vec<(WebhookNotifyConfig, &str)> = vec![
(
WebhookNotifyConfig {
url: String::new(),
..cfg()
},
"url is empty",
),
(
WebhookNotifyConfig {
url: "not a url".to_string(),
..cfg()
},
"not a valid URL",
),
(
WebhookNotifyConfig {
url: "ftp://chat.example.com/hooks/xyz".to_string(),
..cfg()
},
"must be http",
),
(
WebhookNotifyConfig {
method: "GET".to_string(),
..cfg()
},
"unknown method `GET`",
),
(
WebhookNotifyConfig {
headers: BTreeMap::from([("not a header".to_string(), "x".to_string())]),
..cfg()
},
"is not a header name",
),
(
WebhookNotifyConfig {
headers: BTreeMap::from([("x-token".to_string(), "bad\nvalue".to_string())]),
..cfg()
},
"not a valid header value",
),
(
WebhookNotifyConfig {
body: " ".to_string(),
..cfg()
},
"body is empty",
),
(
WebhookNotifyConfig {
body: "{{ message".to_string(),
..cfg()
},
"not a valid template",
),
];
for (config, expected) in cases {
let error = build(&config).unwrap_err().to_string();
assert!(
error.contains(expected) && error.contains("notify.webhook.chat"),
"expected `{expected}` naming the entry, got: {error}"
);
}
}
#[test]
fn a_method_is_case_insensitive() {
let notifier = build(&WebhookNotifyConfig {
method: "put".to_string(),
..cfg()
})
.unwrap();
assert_eq!(notifier.method, Method::PUT);
}
#[test]
fn a_message_holding_quotes_and_newlines_still_renders_valid_json() {
let notifier = build(&cfg()).unwrap();
let event = NotifyEvent::ChallengeFailed(ChallengeFailedData {
profile: "default".to_string(),
order_id: "o1".to_string(),
account_id: "a1".to_string(),
authz_id: "z1".to_string(),
challenge_id: "c1".to_string(),
challenge_type: "http-01".to_string(),
identifier: "www.example.com".to_string(),
error: "fetched \"nonsense\"\nand a backslash \\".to_string(),
client_ip: None,
});
let body = notifier.body_for(&event).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|error| panic!("{error}: {body}"));
let text = parsed["text"].as_str().unwrap();
assert!(text.contains("nonsense"), "{text}");
assert!(text.contains("www.example.com"), "{text}");
}
#[test]
fn a_body_template_sees_the_events_own_fields() {
let notifier = build(&WebhookNotifyConfig {
body: r#"{"chat_id": "-100", "hook": {{ hook | tojson }}, "profile": {{ profile | tojson }}, "text": {{ message | tojson }}}"#
.to_string(),
..cfg()
})
.unwrap();
let body = notifier.body_for(&mounted()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["hook"], "profile_mounted");
assert_eq!(parsed["profile"], "default");
assert_eq!(parsed["chat_id"], "-100");
}
#[tokio::test]
async fn send_uses_the_configured_method_headers_and_body() {
let (addr, rx) = serve_once(hyper::StatusCode::OK, "").await;
let notifier = build(&WebhookNotifyConfig {
url: format!("http://{addr}/hooks/xyz"),
method: "PUT".to_string(),
headers: BTreeMap::from([
("Authorization".to_string(), "Bearer s3cret".to_string()),
(
"content-type".to_string(),
"application/vnd.chat".to_string(),
),
]),
..cfg()
})
.unwrap();
assert_eq!(notifier.name(), "webhook");
notifier
.send(&mounted())
.await
.expect("the webhook accepted the request");
let (method, headers, body) = rx.await.unwrap();
assert_eq!(method, "PUT");
assert_eq!(headers["authorization"], "Bearer s3cret");
assert_eq!(headers["content-type"], "application/vnd.chat");
assert_eq!(headers["user-agent"], "acme-proxy");
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
parsed["text"].as_str().unwrap().contains("default"),
"the rendered text must name the profile: {parsed}"
);
}
#[tokio::test]
async fn a_rejecting_webhook_reports_its_status_and_body() {
let (addr, _rx) = serve_once(hyper::StatusCode::BAD_REQUEST, "invalid_payload").await;
let notifier = build(&WebhookNotifyConfig {
url: format!("http://{addr}/hooks/xyz"),
..cfg()
})
.unwrap();
let error = notifier
.send(&mounted())
.await
.expect_err("400 is not a delivery");
assert!(error.to_string().contains("400"), "{error}");
assert!(error.to_string().contains("invalid_payload"), "{error}");
assert!(
!error.retryable(),
"a 400 is the provider stating a reason, not a bad minute"
);
}
#[tokio::test]
async fn an_unusable_url_is_permanent_and_an_unreachable_host_is_not() {
let tls = Arc::new(crate::http_client::webpki_tls_config());
let url: Url = "ftp://chat.example.com/hooks/xyz".parse().unwrap();
let error = send_request(
&tls,
&crate::testutil::outbound_with(test_resolver()),
&Method::POST,
&url,
&HeaderMap::new(),
Bytes::from_static(b"{}"),
)
.await
.expect_err("ftp is not a webhook transport");
assert!(error.to_string().contains("unsupported scheme"), "{error}");
assert!(!error.retryable(), "{error}");
let url: Url = "http://127.0.0.1:1/hooks/xyz".parse().unwrap();
let error = send_request(
&tls,
&crate::testutil::outbound_with(test_resolver()),
&Method::POST,
&url,
&HeaderMap::new(),
Bytes::from_static(b"{}"),
)
.await
.expect_err("nothing is listening on port 1");
assert!(error.to_string().contains("connecting to"), "{error}");
assert!(
error.retryable(),
"a host that is down may come back: {error}"
);
}
#[test]
fn a_body_that_fails_at_render_time_is_permanent() {
let notifier = build(&WebhookNotifyConfig {
body: "{{ message.no_such_method() }}".to_string(),
..cfg()
})
.unwrap();
let error = notifier.body_for(&mounted()).unwrap_err();
assert!(
error.to_string().contains("notify.webhook.chat.body"),
"{error}"
);
assert!(!error.retryable(), "{error}");
}
#[tokio::test]
async fn a_silent_webhook_times_out_and_is_retryable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
std::future::pending::<()>().await;
drop(stream);
});
let notifier = build(&WebhookNotifyConfig {
url: format!("http://{addr}/hooks/xyz"),
timeout_ms: 100,
..cfg()
})
.unwrap();
let error = notifier
.send(&mounted())
.await
.expect_err("a silent server is not a delivery");
assert!(error.to_string().contains("timed out"), "{error}");
assert!(error.retryable(), "{error}");
}
#[test]
fn only_a_transient_status_is_retried() {
use hyper::StatusCode;
for status in [
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::REQUEST_TIMEOUT,
] {
assert!(retryable_status(status), "{status} must be retried");
}
for status in [
StatusCode::BAD_REQUEST,
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
StatusCode::GONE,
] {
assert!(!retryable_status(status), "{status} must not be retried");
}
}
#[test]
fn debug_renders_neither_the_url_path_nor_a_header_value() {
let notifier = build(&WebhookNotifyConfig {
url: "https://chat.example.com/hooks/T00/B00/s3cret-hook-id".to_string(),
headers: BTreeMap::from([("Authorization".to_string(), "Bearer s3cret".to_string())]),
..cfg()
})
.unwrap();
let rendered = format!("{notifier:?}");
assert!(rendered.contains("chat.example.com"), "{rendered}");
assert!(rendered.contains("authorization"), "{rendered}");
assert!(!rendered.contains("s3cret"), "{rendered}");
}
}