Skip to main content

sentinel_proxy/acme/dns/providers/
webhook.rs

1//! Generic webhook DNS provider
2//!
3//! Allows integration with custom DNS management systems via HTTP webhooks.
4//!
5//! # Webhook API
6//!
7//! ## Create Record
8//! ```text
9//! POST {url}/records
10//! Content-Type: application/json
11//!
12//! {
13//!   "domain": "example.com",
14//!   "record_name": "_acme-challenge",
15//!   "record_type": "TXT",
16//!   "record_value": "challenge-value",
17//!   "ttl": 60
18//! }
19//!
20//! Response:
21//! {
22//!   "record_id": "unique-id"
23//! }
24//! ```
25//!
26//! ## Delete Record
27//! ```text
28//! DELETE {url}/records/{record_id}?domain={domain}
29//!
30//! Response: 200 OK or 204 No Content
31//! ```
32//!
33//! ## Check Domain Support
34//! ```text
35//! GET {url}/domains/{domain}/supported
36//!
37//! Response:
38//! {
39//!   "supported": true
40//! }
41//! ```
42
43use std::time::Duration;
44
45use async_trait::async_trait;
46use reqwest::Client;
47use serde::{Deserialize, Serialize};
48use tracing::debug;
49
50use crate::acme::dns::credentials::Credentials;
51use crate::acme::dns::provider::{DnsProvider, DnsProviderError, DnsResult, CHALLENGE_TTL};
52
53/// Webhook DNS provider for custom integrations
54#[derive(Debug)]
55pub struct WebhookProvider {
56    client: Client,
57    base_url: String,
58    auth_header: Option<String>,
59    credentials: Option<Credentials>,
60}
61
62impl WebhookProvider {
63    /// Create a new webhook DNS provider
64    ///
65    /// # Arguments
66    ///
67    /// * `base_url` - Base URL for the webhook API
68    /// * `auth_header` - Optional custom auth header name (e.g., "X-API-Key")
69    /// * `credentials` - Optional credentials for authentication
70    /// * `timeout` - Request timeout
71    pub fn new(
72        base_url: String,
73        auth_header: Option<String>,
74        credentials: Option<Credentials>,
75        timeout: Duration,
76    ) -> DnsResult<Self> {
77        let client = Client::builder()
78            .timeout(timeout)
79            .build()
80            .map_err(|e| {
81                DnsProviderError::Configuration(format!("Failed to create HTTP client: {}", e))
82            })?;
83
84        // Remove trailing slash from base URL
85        let base_url = base_url.trim_end_matches('/').to_string();
86
87        Ok(Self {
88            client,
89            base_url,
90            auth_header,
91            credentials,
92        })
93    }
94
95    /// Add authentication to a request
96    fn add_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
97        match (&self.auth_header, &self.credentials) {
98            (Some(header), Some(creds)) => {
99                request.header(header.as_str(), creds.as_bearer_token())
100            }
101            (None, Some(creds)) => {
102                request.bearer_auth(creds.as_bearer_token())
103            }
104            _ => request,
105        }
106    }
107}
108
109#[async_trait]
110impl DnsProvider for WebhookProvider {
111    fn name(&self) -> &'static str {
112        "webhook"
113    }
114
115    async fn create_txt_record(
116        &self,
117        domain: &str,
118        record_name: &str,
119        record_value: &str,
120    ) -> DnsResult<String> {
121        debug!(
122            domain = %domain,
123            record_name = %record_name,
124            url = %self.base_url,
125            "Creating TXT record via webhook"
126        );
127
128        let request = CreateRecordRequest {
129            domain: domain.to_string(),
130            record_name: record_name.to_string(),
131            record_type: "TXT".to_string(),
132            record_value: record_value.to_string(),
133            ttl: CHALLENGE_TTL,
134        };
135
136        let request_builder = self
137            .client
138            .post(format!("{}/records", self.base_url))
139            .json(&request);
140
141        let response = self
142            .add_auth(request_builder)
143            .send()
144            .await
145            .map_err(|e| {
146                if e.is_timeout() {
147                    DnsProviderError::Timeout { elapsed_secs: 30 }
148                } else {
149                    DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
150                }
151            })?;
152
153        if response.status() == reqwest::StatusCode::UNAUTHORIZED
154            || response.status() == reqwest::StatusCode::FORBIDDEN
155        {
156            return Err(DnsProviderError::Authentication(
157                "Webhook authentication failed".to_string(),
158            ));
159        }
160
161        if !response.status().is_success() {
162            let status = response.status();
163            let body = response.text().await.unwrap_or_default();
164            return Err(DnsProviderError::RecordCreation {
165                record_name: record_name.to_string(),
166                message: format!("Webhook returned HTTP {} - {}", status, body),
167            });
168        }
169
170        let record_response: CreateRecordResponse = response.json().await.map_err(|e| {
171            DnsProviderError::RecordCreation {
172                record_name: record_name.to_string(),
173                message: format!("Failed to parse webhook response: {}", e),
174            }
175        })?;
176
177        debug!(record_id = %record_response.record_id, "TXT record created via webhook");
178        Ok(record_response.record_id)
179    }
180
181    async fn delete_txt_record(&self, domain: &str, record_id: &str) -> DnsResult<()> {
182        debug!(
183            domain = %domain,
184            record_id = %record_id,
185            "Deleting TXT record via webhook"
186        );
187
188        let request_builder = self
189            .client
190            .delete(format!("{}/records/{}", self.base_url, record_id))
191            .query(&[("domain", domain)]);
192
193        let response = self
194            .add_auth(request_builder)
195            .send()
196            .await
197            .map_err(|e| {
198                if e.is_timeout() {
199                    DnsProviderError::Timeout { elapsed_secs: 30 }
200                } else {
201                    DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
202                }
203            })?;
204
205        // 404 is acceptable - record might already be deleted
206        if response.status() == reqwest::StatusCode::NOT_FOUND {
207            debug!(record_id = %record_id, "Record already deleted");
208            return Ok(());
209        }
210
211        if !response.status().is_success() {
212            let status = response.status();
213            let body = response.text().await.unwrap_or_default();
214            return Err(DnsProviderError::RecordDeletion {
215                record_id: record_id.to_string(),
216                message: format!("Webhook returned HTTP {} - {}", status, body),
217            });
218        }
219
220        debug!(record_id = %record_id, "TXT record deleted via webhook");
221        Ok(())
222    }
223
224    async fn supports_domain(&self, domain: &str) -> DnsResult<bool> {
225        let request_builder = self
226            .client
227            .get(format!("{}/domains/{}/supported", self.base_url, domain));
228
229        let response = self
230            .add_auth(request_builder)
231            .send()
232            .await
233            .map_err(|e| {
234                if e.is_timeout() {
235                    DnsProviderError::Timeout { elapsed_secs: 30 }
236                } else {
237                    DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
238                }
239            })?;
240
241        // 404 means domain not supported
242        if response.status() == reqwest::StatusCode::NOT_FOUND {
243            return Ok(false);
244        }
245
246        if !response.status().is_success() {
247            let status = response.status();
248            let body = response.text().await.unwrap_or_default();
249            return Err(DnsProviderError::ApiRequest(format!(
250                "Webhook returned HTTP {} - {}",
251                status, body
252            )));
253        }
254
255        let support_response: DomainSupportResponse = response.json().await.map_err(|e| {
256            DnsProviderError::ApiRequest(format!("Failed to parse webhook response: {}", e))
257        })?;
258
259        Ok(support_response.supported)
260    }
261}
262
263// Webhook API types
264
265#[derive(Debug, Serialize)]
266struct CreateRecordRequest {
267    domain: String,
268    record_name: String,
269    record_type: String,
270    record_value: String,
271    ttl: u32,
272}
273
274#[derive(Debug, Deserialize)]
275struct CreateRecordResponse {
276    record_id: String,
277}
278
279#[derive(Debug, Deserialize)]
280struct DomainSupportResponse {
281    supported: bool,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_base_url_normalization() {
290        let provider = WebhookProvider::new(
291            "https://example.com/api/".to_string(),
292            None,
293            None,
294            Duration::from_secs(30),
295        )
296        .unwrap();
297
298        assert_eq!(provider.base_url, "https://example.com/api");
299    }
300
301    #[test]
302    fn test_without_trailing_slash() {
303        let provider = WebhookProvider::new(
304            "https://example.com/api".to_string(),
305            None,
306            None,
307            Duration::from_secs(30),
308        )
309        .unwrap();
310
311        assert_eq!(provider.base_url, "https://example.com/api");
312    }
313}