1use serde_json::Value;
13use tracing::instrument;
14
15use crate::control_plane::config::VendorKind;
16use crate::core::dns::capabilities::VendorCapabilities;
17use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
18use crate::core::dns::records::RecordData;
19use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
20use crate::core::dns::service::{
21 AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
22 RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
23};
24use crate::core::error::{Error, Result};
25use crate::vendors::pihole::client::PiholeClient;
26use crate::vendors::pihole::mapping::*;
27
28impl DnsVendor for PiholeClient {
31 fn kind(&self) -> VendorKind {
32 VendorKind::Pihole
33 }
34
35 fn capabilities(&self) -> VendorCapabilities {
36 VendorCapabilities {
37 zones: false,
38 records: true,
39 cache: true,
40 access_lists: true,
41 settings: true,
42 zone_import: false,
43 zone_export: false,
44 logs: false,
45 }
46 }
47}
48
49impl ZoneRead for PiholeClient {
52 async fn list_zones<'a>(&'a self, _page: u32, _per_page: u32) -> Result<Value> {
53 Err(Error::unsupported("Pi-hole", "zone listing"))
54 }
55
56 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_records"))]
57 async fn list_records<'a>(
58 &'a self,
59 domain: &'a str,
60 zone: Option<&'a str>,
61 options: ListRecordsOptions,
62 ) -> Result<ListRecordsResponse> {
63 let inferred;
64 let zone_name = match zone {
65 Some(z) => z,
66 None => {
67 inferred = infer_zone(domain);
68 &inferred
69 }
70 };
71
72 let dns_data = self.get("/api/dns/local_records", &[]).await?;
73 let cname_data = self.get("/api/dns/local_cnames", &[]).await?;
74
75 let mut records: Vec<ZoneRecord> = Vec::new();
76
77 let domain_lc = domain.trim_end_matches('.').to_ascii_lowercase();
78 let domain_suffix = format!(".{domain_lc}");
79
80 if let Some(arr) = dns_data.get("dns").and_then(|d| d.as_array()) {
81 for entry in arr {
82 let host = entry.get("host").and_then(|h| h.as_str()).unwrap_or("");
83 let host_lc = host.trim_end_matches('.').to_ascii_lowercase();
84 if domain.is_empty()
85 || host_lc == domain_lc
86 || (options.all_subdomains && host_lc.ends_with(&domain_suffix))
87 {
88 records.push(local_dns_to_zone_record(entry, zone_name));
89 }
90 }
91 }
92
93 if let Some(arr) = cname_data.get("cnames").and_then(|c| c.as_array()) {
94 for entry in arr {
95 let cname_domain = entry.get("domain").and_then(|d| d.as_str()).unwrap_or("");
96 let cname_lc = cname_domain.trim_end_matches('.').to_ascii_lowercase();
97 if domain.is_empty()
98 || cname_lc == domain_lc
99 || (options.all_subdomains && cname_lc.ends_with(&domain_suffix))
100 {
101 records.push(local_cname_to_zone_record(entry, zone_name));
102 }
103 }
104 }
105
106 let zone_info = ZoneInfo {
107 id: None,
108 name: zone_name.to_string(),
109 zone_type: "Local".to_string(),
110 disabled: false,
111 dnssec_status: None,
112 };
113
114 Ok(ListRecordsResponse::single(zone_info, records))
115 }
116}
117
118impl ZoneWrite for PiholeClient {
121 async fn create_zone<'a>(&'a self, _zone: &'a str, _zone_type: &'a str) -> Result<Value> {
122 Err(Error::unsupported("Pi-hole", "zone creation"))
123 }
124
125 async fn delete_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
126 Err(Error::unsupported("Pi-hole", "zone deletion"))
127 }
128
129 async fn enable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
130 Err(Error::unsupported("Pi-hole", "enable zone"))
131 }
132
133 async fn disable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
134 Err(Error::unsupported("Pi-hole", "disable zone"))
135 }
136}
137
138impl RecordWrite for PiholeClient {
141 #[instrument(
142 skip(self, record),
143 fields(vendor = "pihole", operation = "add_record")
144 )]
145 async fn add_record<'a>(
146 &'a self,
147 _zone: &'a str,
148 domain: &'a str,
149 _ttl: u32,
150 record: &'a RecordData,
151 ) -> Result<Value> {
152 let body = record_data_to_local_dns_body(domain, record).ok_or_else(|| {
153 Error::unsupported(
154 "Pi-hole",
155 "record type — only A, AAAA, and CNAME are supported",
156 )
157 })?;
158
159 let endpoint = match record {
160 RecordData::Cname { .. } => "/api/dns/local_cnames",
161 _ => "/api/dns/local_records",
162 };
163
164 self.post(endpoint, &body).await
165 }
166
167 #[instrument(
168 skip(self, type_params),
169 fields(vendor = "pihole", operation = "delete_record")
170 )]
171 async fn delete_record<'a>(
172 &'a self,
173 _zone: &'a str,
174 domain: &'a str,
175 type_params: &'a [(&'a str, String)],
176 ) -> Result<Value> {
177 let record_type = type_params
178 .iter()
179 .find(|(k, _)| *k == "type")
180 .map(|(_, v)| v.as_str())
181 .unwrap_or("A");
182
183 let ip = type_params
184 .iter()
185 .find(|(k, _)| *k == "ipAddress" || *k == "ip")
186 .map(|(_, v)| v.clone());
187
188 let target = type_params
189 .iter()
190 .find(|(k, _)| *k == "cname")
191 .map(|(_, v)| v.clone());
192
193 match record_type.to_uppercase().as_str() {
194 "A" | "AAAA" => {
195 let ip_val = ip.ok_or_else(|| {
196 Error::parse("delete A/AAAA record requires 'ip' or 'ipAddress' parameter")
197 })?;
198 let body = serde_json::json!({ "ip": ip_val, "host": domain });
199 self.delete_with_body("/api/dns/local_records", &body).await
200 }
201 "CNAME" => {
202 let cname_target = target.ok_or_else(|| {
203 Error::parse("delete CNAME record requires 'cname' parameter")
204 })?;
205 let body = serde_json::json!({ "domain": domain, "target": cname_target });
206 self.delete_with_body("/api/dns/local_cnames", &body).await
207 }
208 _ => Err(Error::unsupported(
209 "Pi-hole",
210 "record type — only A, AAAA, and CNAME can be deleted",
211 )),
212 }
213 }
214}
215
216impl CacheRead for PiholeClient {
219 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_cache"))]
220 async fn list_cache<'a>(&'a self, _domain: &'a str) -> Result<Value> {
221 self.get("/api/cache", &[]).await
222 }
223}
224
225impl CacheWrite for PiholeClient {
228 async fn delete_cache_zone<'a>(&'a self, _domain: &'a str) -> Result<Value> {
229 Err(Error::unsupported("Pi-hole", "per-zone cache deletion"))
230 }
231
232 #[instrument(skip(self), fields(vendor = "pihole", operation = "flush_cache"))]
233 async fn flush_cache(&self) -> Result<Value> {
234 self.post("/api/cache/flush", &serde_json::json!({})).await
235 }
236}
237
238impl StatsRead for PiholeClient {
241 #[instrument(skip(self), fields(vendor = "pihole", operation = "get_stats"))]
242 async fn get_stats<'a>(&'a self, stats_type: &'a str) -> Result<Value> {
243 match stats_type {
244 "overTime" | "overtime" | "history" => {
245 self.get("/api/stats/overTime/history", &[]).await
246 }
247 "clients" => self.get("/api/stats/overTime/clients", &[]).await,
248 _ => self.get("/api/stats/summary", &[]).await,
249 }
250 }
251}
252
253impl AccessListRead for PiholeClient {
256 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_blocked"))]
257 async fn list_blocked(&self) -> Result<Value> {
258 self.get("/api/domains", &[("type", "block".to_string())])
259 .await
260 }
261
262 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_allowed"))]
263 async fn list_allowed(&self) -> Result<Value> {
264 self.get("/api/domains", &[("type", "allow".to_string())])
265 .await
266 }
267}
268
269impl AccessListWrite for PiholeClient {
272 #[instrument(skip(self), fields(vendor = "pihole", operation = "add_blocked"))]
273 async fn add_blocked<'a>(&'a self, domain: &'a str) -> Result<Value> {
274 self.post(
275 &format!("/api/domains/block/exact/{domain}"),
276 &serde_json::json!({}),
277 )
278 .await
279 }
280
281 #[instrument(skip(self), fields(vendor = "pihole", operation = "delete_blocked"))]
282 async fn delete_blocked<'a>(&'a self, domain: &'a str) -> Result<Value> {
283 self.delete(&format!("/api/domains/block/exact/{domain}"))
284 .await
285 }
286
287 #[instrument(skip(self), fields(vendor = "pihole", operation = "add_allowed"))]
288 async fn add_allowed<'a>(&'a self, domain: &'a str) -> Result<Value> {
289 self.post(
290 &format!("/api/domains/allow/exact/{domain}"),
291 &serde_json::json!({}),
292 )
293 .await
294 }
295
296 #[instrument(skip(self), fields(vendor = "pihole", operation = "delete_allowed"))]
297 async fn delete_allowed<'a>(&'a self, domain: &'a str) -> Result<Value> {
298 self.delete(&format!("/api/domains/allow/exact/{domain}"))
299 .await
300 }
301}
302
303impl ZoneImport for PiholeClient {
306 async fn import_zone_file<'a>(
307 &'a self,
308 _zone: &'a str,
309 _file_name: String,
310 _file_bytes: Vec<u8>,
311 _overwrite: bool,
312 _overwrite_zone: bool,
313 _overwrite_soa_serial: bool,
314 ) -> Result<Value> {
315 Err(Error::unsupported("Pi-hole", "zone import"))
316 }
317}
318
319impl ZoneExport for PiholeClient {
320 async fn export_zone_file<'a>(&'a self, _zone: &'a str) -> Result<String> {
321 Err(Error::unsupported("Pi-hole", "zone export"))
322 }
323}
324
325impl SettingsRead for PiholeClient {
328 #[instrument(skip(self), fields(vendor = "pihole", operation = "get_settings"))]
329 async fn get_settings(&self) -> Result<Value> {
330 self.get("/api/config", &[]).await
331 }
332}
333
334impl LogsRead for PiholeClient {
337 async fn get_logs(&self, _options: LogsOptions) -> Result<Vec<LogLine>> {
338 Err(Error::unsupported("Pi-hole", "logs"))
339 }
340}
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn make_client() -> PiholeClient {
349 PiholeClient::new(
350 "http://pi.hole".to_string(),
351 crate::core::secret::ApiToken::new("test-password"),
352 )
353 .unwrap()
354 }
355
356 #[test]
357 fn kind_returns_pihole() {
358 assert_eq!(make_client().kind(), VendorKind::Pihole);
359 }
360
361 #[test]
362 fn capabilities_match_supported_operations() {
363 let caps = make_client().capabilities();
364 assert!(!caps.zones);
365 assert!(caps.records);
366 assert!(caps.cache);
367 assert!(caps.access_lists);
368 assert!(caps.settings);
369 assert!(!caps.zone_import);
370 assert!(!caps.zone_export);
371 }
372
373 #[tokio::test]
374 async fn list_zones_is_unsupported() {
375 let err = make_client().list_zones(1, 100).await.unwrap_err();
376 assert!(matches!(
377 err,
378 Error::Unsupported {
379 vendor: "Pi-hole",
380 ..
381 }
382 ));
383 }
384
385 #[tokio::test]
386 async fn create_zone_is_unsupported() {
387 let err = make_client()
388 .create_zone("example.com", "Primary")
389 .await
390 .unwrap_err();
391 assert!(matches!(
392 err,
393 Error::Unsupported {
394 vendor: "Pi-hole",
395 ..
396 }
397 ));
398 }
399
400 #[tokio::test]
401 async fn delete_zone_is_unsupported() {
402 let err = make_client().delete_zone("example.com").await.unwrap_err();
403 assert!(matches!(
404 err,
405 Error::Unsupported {
406 vendor: "Pi-hole",
407 ..
408 }
409 ));
410 }
411
412 #[tokio::test]
413 async fn enable_zone_is_unsupported() {
414 let err = make_client().enable_zone("example.com").await.unwrap_err();
415 assert!(matches!(
416 err,
417 Error::Unsupported {
418 vendor: "Pi-hole",
419 ..
420 }
421 ));
422 }
423
424 #[tokio::test]
425 async fn disable_zone_is_unsupported() {
426 let err = make_client().disable_zone("example.com").await.unwrap_err();
427 assert!(matches!(
428 err,
429 Error::Unsupported {
430 vendor: "Pi-hole",
431 ..
432 }
433 ));
434 }
435
436 #[tokio::test]
437 async fn delete_cache_zone_is_unsupported() {
438 let err = make_client()
439 .delete_cache_zone("example.com")
440 .await
441 .unwrap_err();
442 assert!(matches!(
443 err,
444 Error::Unsupported {
445 vendor: "Pi-hole",
446 ..
447 }
448 ));
449 }
450
451 #[tokio::test]
452 async fn zone_import_is_unsupported() {
453 let err = make_client()
454 .import_zone_file("example.com", "zone.txt".into(), vec![], true, false, false)
455 .await
456 .unwrap_err();
457 assert!(matches!(
458 err,
459 Error::Unsupported {
460 vendor: "Pi-hole",
461 ..
462 }
463 ));
464 }
465
466 #[tokio::test]
467 async fn zone_export_is_unsupported() {
468 let err = make_client()
469 .export_zone_file("example.com")
470 .await
471 .unwrap_err();
472 assert!(matches!(
473 err,
474 Error::Unsupported {
475 vendor: "Pi-hole",
476 ..
477 }
478 ));
479 }
480
481 #[tokio::test]
482 async fn add_unsupported_record_type_is_unsupported() {
483 let record = RecordData::Mx {
484 preference: 10,
485 exchange: "mail.example.com".into(),
486 };
487 let err = make_client()
488 .add_record("home.lan", "example.com", 300, &record)
489 .await
490 .unwrap_err();
491 assert!(matches!(
492 err,
493 Error::Unsupported {
494 vendor: "Pi-hole",
495 ..
496 }
497 ));
498 }
499}