Skip to main content

grapsus_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().timeout(timeout).build().map_err(|e| {
78            DnsProviderError::Configuration(format!("Failed to create HTTP client: {}", e))
79        })?;
80
81        // Remove trailing slash from base URL
82        let base_url = base_url.trim_end_matches('/').to_string();
83
84        Ok(Self {
85            client,
86            base_url,
87            auth_header,
88            credentials,
89        })
90    }
91
92    /// Add authentication to a request
93    fn add_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
94        match (&self.auth_header, &self.credentials) {
95            (Some(header), Some(creds)) => request.header(header.as_str(), creds.as_bearer_token()),
96            (None, Some(creds)) => request.bearer_auth(creds.as_bearer_token()),
97            _ => request,
98        }
99    }
100}
101
102#[async_trait]
103impl DnsProvider for WebhookProvider {
104    fn name(&self) -> &'static str {
105        "webhook"
106    }
107
108    async fn create_txt_record(
109        &self,
110        domain: &str,
111        record_name: &str,
112        record_value: &str,
113    ) -> DnsResult<String> {
114        debug!(
115            domain = %domain,
116            record_name = %record_name,
117            url = %self.base_url,
118            "Creating TXT record via webhook"
119        );
120
121        let request = CreateRecordRequest {
122            domain: domain.to_string(),
123            record_name: record_name.to_string(),
124            record_type: "TXT".to_string(),
125            record_value: record_value.to_string(),
126            ttl: CHALLENGE_TTL,
127        };
128
129        let request_builder = self
130            .client
131            .post(format!("{}/records", self.base_url))
132            .json(&request);
133
134        let response = self.add_auth(request_builder).send().await.map_err(|e| {
135            if e.is_timeout() {
136                DnsProviderError::Timeout { elapsed_secs: 30 }
137            } else {
138                DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
139            }
140        })?;
141
142        if response.status() == reqwest::StatusCode::UNAUTHORIZED
143            || response.status() == reqwest::StatusCode::FORBIDDEN
144        {
145            return Err(DnsProviderError::Authentication(
146                "Webhook authentication failed".to_string(),
147            ));
148        }
149
150        if !response.status().is_success() {
151            let status = response.status();
152            let body = response.text().await.unwrap_or_default();
153            return Err(DnsProviderError::RecordCreation {
154                record_name: record_name.to_string(),
155                message: format!("Webhook returned HTTP {} - {}", status, body),
156            });
157        }
158
159        let record_response: CreateRecordResponse =
160            response
161                .json()
162                .await
163                .map_err(|e| DnsProviderError::RecordCreation {
164                    record_name: record_name.to_string(),
165                    message: format!("Failed to parse webhook response: {}", e),
166                })?;
167
168        debug!(record_id = %record_response.record_id, "TXT record created via webhook");
169        Ok(record_response.record_id)
170    }
171
172    async fn delete_txt_record(&self, domain: &str, record_id: &str) -> DnsResult<()> {
173        debug!(
174            domain = %domain,
175            record_id = %record_id,
176            "Deleting TXT record via webhook"
177        );
178
179        let request_builder = self
180            .client
181            .delete(format!("{}/records/{}", self.base_url, record_id))
182            .query(&[("domain", domain)]);
183
184        let response = self.add_auth(request_builder).send().await.map_err(|e| {
185            if e.is_timeout() {
186                DnsProviderError::Timeout { elapsed_secs: 30 }
187            } else {
188                DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
189            }
190        })?;
191
192        // 404 is acceptable - record might already be deleted
193        if response.status() == reqwest::StatusCode::NOT_FOUND {
194            debug!(record_id = %record_id, "Record already deleted");
195            return Ok(());
196        }
197
198        if !response.status().is_success() {
199            let status = response.status();
200            let body = response.text().await.unwrap_or_default();
201            return Err(DnsProviderError::RecordDeletion {
202                record_id: record_id.to_string(),
203                message: format!("Webhook returned HTTP {} - {}", status, body),
204            });
205        }
206
207        debug!(record_id = %record_id, "TXT record deleted via webhook");
208        Ok(())
209    }
210
211    async fn supports_domain(&self, domain: &str) -> DnsResult<bool> {
212        let request_builder = self
213            .client
214            .get(format!("{}/domains/{}/supported", self.base_url, domain));
215
216        let response = self.add_auth(request_builder).send().await.map_err(|e| {
217            if e.is_timeout() {
218                DnsProviderError::Timeout { elapsed_secs: 30 }
219            } else {
220                DnsProviderError::ApiRequest(format!("Webhook request failed: {}", e))
221            }
222        })?;
223
224        // 404 means domain not supported
225        if response.status() == reqwest::StatusCode::NOT_FOUND {
226            return Ok(false);
227        }
228
229        if !response.status().is_success() {
230            let status = response.status();
231            let body = response.text().await.unwrap_or_default();
232            return Err(DnsProviderError::ApiRequest(format!(
233                "Webhook returned HTTP {} - {}",
234                status, body
235            )));
236        }
237
238        let support_response: DomainSupportResponse = response.json().await.map_err(|e| {
239            DnsProviderError::ApiRequest(format!("Failed to parse webhook response: {}", e))
240        })?;
241
242        Ok(support_response.supported)
243    }
244}
245
246// Webhook API types
247
248#[derive(Debug, Serialize)]
249struct CreateRecordRequest {
250    domain: String,
251    record_name: String,
252    record_type: String,
253    record_value: String,
254    ttl: u32,
255}
256
257#[derive(Debug, Deserialize)]
258struct CreateRecordResponse {
259    record_id: String,
260}
261
262#[derive(Debug, Deserialize)]
263struct DomainSupportResponse {
264    supported: bool,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_base_url_normalization() {
273        let provider = WebhookProvider::new(
274            "https://example.com/api/".to_string(),
275            None,
276            None,
277            Duration::from_secs(30),
278        )
279        .unwrap();
280
281        assert_eq!(provider.base_url, "https://example.com/api");
282    }
283
284    #[test]
285    fn test_without_trailing_slash() {
286        let provider = WebhookProvider::new(
287            "https://example.com/api".to_string(),
288            None,
289            None,
290            Duration::from_secs(30),
291        )
292        .unwrap();
293
294        assert_eq!(provider.base_url, "https://example.com/api");
295    }
296}