grapsus_proxy/acme/dns/providers/
webhook.rs1use 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#[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 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 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 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 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 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#[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}