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 if let Some(arr) = dns_data.get("dns").and_then(|d| d.as_array()) {
78 for entry in arr {
79 let host = entry.get("host").and_then(|h| h.as_str()).unwrap_or("");
80 if zone.is_none() || host == domain || host.ends_with(&format!(".{domain}")) {
81 records.push(local_dns_to_zone_record(entry, zone_name));
82 }
83 }
84 }
85
86 if let Some(arr) = cname_data.get("cnames").and_then(|c| c.as_array()) {
87 for entry in arr {
88 let cname_domain = entry.get("domain").and_then(|d| d.as_str()).unwrap_or("");
89 if zone.is_none()
90 || cname_domain == domain
91 || cname_domain.ends_with(&format!(".{domain}"))
92 {
93 records.push(local_cname_to_zone_record(entry, zone_name));
94 }
95 }
96 }
97
98 let zone_info = ZoneInfo {
99 id: None,
100 name: zone_name.to_string(),
101 zone_type: "Local".to_string(),
102 disabled: false,
103 dnssec_status: None,
104 };
105
106 Ok(ListRecordsResponse::single(zone_info, records))
107 }
108}
109
110impl ZoneWrite for PiholeClient {
113 async fn create_zone<'a>(&'a self, _zone: &'a str, _zone_type: &'a str) -> Result<Value> {
114 Err(Error::unsupported("Pi-hole", "zone creation"))
115 }
116
117 async fn delete_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
118 Err(Error::unsupported("Pi-hole", "zone deletion"))
119 }
120
121 async fn enable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
122 Err(Error::unsupported("Pi-hole", "enable zone"))
123 }
124
125 async fn disable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
126 Err(Error::unsupported("Pi-hole", "disable zone"))
127 }
128}
129
130impl RecordWrite for PiholeClient {
133 #[instrument(
134 skip(self, record),
135 fields(vendor = "pihole", operation = "add_record")
136 )]
137 async fn add_record<'a>(
138 &'a self,
139 _zone: &'a str,
140 domain: &'a str,
141 _ttl: u32,
142 record: &'a RecordData,
143 ) -> Result<Value> {
144 let body = record_data_to_local_dns_body(domain, record).ok_or_else(|| {
145 Error::unsupported(
146 "Pi-hole",
147 "record type — only A, AAAA, and CNAME are supported",
148 )
149 })?;
150
151 let endpoint = match record {
152 RecordData::Cname { .. } => "/api/dns/local_cnames",
153 _ => "/api/dns/local_records",
154 };
155
156 self.post(endpoint, &body).await
157 }
158
159 #[instrument(
160 skip(self, type_params),
161 fields(vendor = "pihole", operation = "delete_record")
162 )]
163 async fn delete_record<'a>(
164 &'a self,
165 _zone: &'a str,
166 domain: &'a str,
167 type_params: &'a [(&'a str, String)],
168 ) -> Result<Value> {
169 let record_type = type_params
170 .iter()
171 .find(|(k, _)| *k == "type")
172 .map(|(_, v)| v.as_str())
173 .unwrap_or("A");
174
175 let ip = type_params
176 .iter()
177 .find(|(k, _)| *k == "ipAddress" || *k == "ip")
178 .map(|(_, v)| v.clone());
179
180 let target = type_params
181 .iter()
182 .find(|(k, _)| *k == "cname")
183 .map(|(_, v)| v.clone());
184
185 match record_type.to_uppercase().as_str() {
186 "A" | "AAAA" => {
187 let ip_val = ip.ok_or_else(|| {
188 Error::parse("delete A/AAAA record requires 'ip' or 'ipAddress' parameter")
189 })?;
190 let body = serde_json::json!({ "ip": ip_val, "host": domain });
191 self.delete_with_body("/api/dns/local_records", &body).await
192 }
193 "CNAME" => {
194 let cname_target = target.ok_or_else(|| {
195 Error::parse("delete CNAME record requires 'cname' parameter")
196 })?;
197 let body = serde_json::json!({ "domain": domain, "target": cname_target });
198 self.delete_with_body("/api/dns/local_cnames", &body).await
199 }
200 _ => Err(Error::unsupported(
201 "Pi-hole",
202 "record type — only A, AAAA, and CNAME can be deleted",
203 )),
204 }
205 }
206}
207
208impl CacheRead for PiholeClient {
211 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_cache"))]
212 async fn list_cache<'a>(&'a self, _domain: &'a str) -> Result<Value> {
213 self.get("/api/cache", &[]).await
214 }
215}
216
217impl CacheWrite for PiholeClient {
220 async fn delete_cache_zone<'a>(&'a self, _domain: &'a str) -> Result<Value> {
221 Err(Error::unsupported("Pi-hole", "per-zone cache deletion"))
222 }
223
224 #[instrument(skip(self), fields(vendor = "pihole", operation = "flush_cache"))]
225 async fn flush_cache(&self) -> Result<Value> {
226 self.post("/api/cache/flush", &serde_json::json!({})).await
227 }
228}
229
230impl StatsRead for PiholeClient {
233 #[instrument(skip(self), fields(vendor = "pihole", operation = "get_stats"))]
234 async fn get_stats<'a>(&'a self, stats_type: &'a str) -> Result<Value> {
235 match stats_type {
236 "overTime" | "overtime" | "history" => {
237 self.get("/api/stats/overTime/history", &[]).await
238 }
239 "clients" => self.get("/api/stats/overTime/clients", &[]).await,
240 _ => self.get("/api/stats/summary", &[]).await,
241 }
242 }
243}
244
245impl AccessListRead for PiholeClient {
248 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_blocked"))]
249 async fn list_blocked(&self) -> Result<Value> {
250 self.get("/api/domains", &[("type", "block".to_string())])
251 .await
252 }
253
254 #[instrument(skip(self), fields(vendor = "pihole", operation = "list_allowed"))]
255 async fn list_allowed(&self) -> Result<Value> {
256 self.get("/api/domains", &[("type", "allow".to_string())])
257 .await
258 }
259}
260
261impl AccessListWrite for PiholeClient {
264 #[instrument(skip(self), fields(vendor = "pihole", operation = "add_blocked"))]
265 async fn add_blocked<'a>(&'a self, domain: &'a str) -> Result<Value> {
266 self.post(
267 &format!("/api/domains/block/exact/{domain}"),
268 &serde_json::json!({}),
269 )
270 .await
271 }
272
273 #[instrument(skip(self), fields(vendor = "pihole", operation = "delete_blocked"))]
274 async fn delete_blocked<'a>(&'a self, domain: &'a str) -> Result<Value> {
275 self.delete(&format!("/api/domains/block/exact/{domain}"))
276 .await
277 }
278
279 #[instrument(skip(self), fields(vendor = "pihole", operation = "add_allowed"))]
280 async fn add_allowed<'a>(&'a self, domain: &'a str) -> Result<Value> {
281 self.post(
282 &format!("/api/domains/allow/exact/{domain}"),
283 &serde_json::json!({}),
284 )
285 .await
286 }
287
288 #[instrument(skip(self), fields(vendor = "pihole", operation = "delete_allowed"))]
289 async fn delete_allowed<'a>(&'a self, domain: &'a str) -> Result<Value> {
290 self.delete(&format!("/api/domains/allow/exact/{domain}"))
291 .await
292 }
293}
294
295impl ZoneImport for PiholeClient {
298 async fn import_zone_file<'a>(
299 &'a self,
300 _zone: &'a str,
301 _file_name: String,
302 _file_bytes: Vec<u8>,
303 _overwrite: bool,
304 _overwrite_zone: bool,
305 _overwrite_soa_serial: bool,
306 ) -> Result<Value> {
307 Err(Error::unsupported("Pi-hole", "zone import"))
308 }
309}
310
311impl ZoneExport for PiholeClient {
312 async fn export_zone_file<'a>(&'a self, _zone: &'a str) -> Result<String> {
313 Err(Error::unsupported("Pi-hole", "zone export"))
314 }
315}
316
317impl SettingsRead for PiholeClient {
320 #[instrument(skip(self), fields(vendor = "pihole", operation = "get_settings"))]
321 async fn get_settings(&self) -> Result<Value> {
322 self.get("/api/config", &[]).await
323 }
324}
325
326impl LogsRead for PiholeClient {
329 async fn get_logs(&self, _options: LogsOptions) -> Result<Vec<LogLine>> {
330 Err(Error::unsupported("Pi-hole", "logs"))
331 }
332}
333
334#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn make_client() -> PiholeClient {
341 PiholeClient::new(
342 "http://pi.hole".to_string(),
343 crate::core::secret::ApiToken::new("test-password"),
344 )
345 .unwrap()
346 }
347
348 #[test]
349 fn kind_returns_pihole() {
350 assert_eq!(make_client().kind(), VendorKind::Pihole);
351 }
352
353 #[test]
354 fn capabilities_match_supported_operations() {
355 let caps = make_client().capabilities();
356 assert!(!caps.zones);
357 assert!(caps.records);
358 assert!(caps.cache);
359 assert!(caps.access_lists);
360 assert!(caps.settings);
361 assert!(!caps.zone_import);
362 assert!(!caps.zone_export);
363 }
364
365 #[tokio::test]
366 async fn list_zones_is_unsupported() {
367 let err = make_client().list_zones(1, 100).await.unwrap_err();
368 assert!(matches!(
369 err,
370 Error::Unsupported {
371 vendor: "Pi-hole",
372 ..
373 }
374 ));
375 }
376
377 #[tokio::test]
378 async fn create_zone_is_unsupported() {
379 let err = make_client()
380 .create_zone("example.com", "Primary")
381 .await
382 .unwrap_err();
383 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
384 }
385
386 #[tokio::test]
387 async fn delete_zone_is_unsupported() {
388 let err = make_client().delete_zone("example.com").await.unwrap_err();
389 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
390 }
391
392 #[tokio::test]
393 async fn enable_zone_is_unsupported() {
394 let err = make_client().enable_zone("example.com").await.unwrap_err();
395 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
396 }
397
398 #[tokio::test]
399 async fn disable_zone_is_unsupported() {
400 let err = make_client()
401 .disable_zone("example.com")
402 .await
403 .unwrap_err();
404 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
405 }
406
407 #[tokio::test]
408 async fn delete_cache_zone_is_unsupported() {
409 let err = make_client()
410 .delete_cache_zone("example.com")
411 .await
412 .unwrap_err();
413 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
414 }
415
416 #[tokio::test]
417 async fn zone_import_is_unsupported() {
418 let err = make_client()
419 .import_zone_file("example.com", "zone.txt".into(), vec![], true, false, false)
420 .await
421 .unwrap_err();
422 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
423 }
424
425 #[tokio::test]
426 async fn zone_export_is_unsupported() {
427 let err = make_client()
428 .export_zone_file("example.com")
429 .await
430 .unwrap_err();
431 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
432 }
433
434 #[tokio::test]
435 async fn add_unsupported_record_type_is_unsupported() {
436 let record = RecordData::Mx {
437 preference: 10,
438 exchange: "mail.example.com".into(),
439 };
440 let err = make_client()
441 .add_record("home.lan", "example.com", 300, &record)
442 .await
443 .unwrap_err();
444 assert!(matches!(err, Error::Unsupported { vendor: "Pi-hole", .. }));
445 }
446}