sentinel_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()
78 .timeout(timeout)
79 .build()
80 .map_err(|e| {
81 DnsProviderError::Configuration(format!("Failed to create HTTP client: {}", e))
82 })?;
83
84 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 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 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 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#[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}