Skip to main content

dnslib/vendors/pihole/
service.rs

1//! Pi-hole v6 implementations of the vendor-neutral DNS service traits.
2//!
3//! Pi-hole is a DNS sinkhole and ad-blocker with a REST API for managing:
4//!   - Local DNS records (A, AAAA, CNAME)
5//!   - Domain allow/block lists
6//!   - Query cache
7//!   - Statistics
8//!   - Server configuration
9//!
10//! Zone management (create/delete/import/export) is not supported.
11
12use 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
28// ─── DnsVendor ────────────────────────────────────────────────────────────────
29
30impl 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
49// ─── ZoneRead ─────────────────────────────────────────────────────────────────
50
51impl 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
118// ─── ZoneWrite ────────────────────────────────────────────────────────────────
119
120impl 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
138// ─── RecordWrite ──────────────────────────────────────────────────────────────
139
140impl 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
216// ─── CacheRead ────────────────────────────────────────────────────────────────
217
218impl 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
225// ─── CacheWrite ───────────────────────────────────────────────────────────────
226
227impl 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
238// ─── StatsRead ────────────────────────────────────────────────────────────────
239
240impl 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
253// ─── AccessListRead ───────────────────────────────────────────────────────────
254
255impl 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
269// ─── AccessListWrite ──────────────────────────────────────────────────────────
270
271impl 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
303// ─── ZoneImport / ZoneExport ──────────────────────────────────────────────────
304
305impl 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
325// ─── SettingsRead ─────────────────────────────────────────────────────────────
326
327impl 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
334// ─── LogsRead ─────────────────────────────────────────────────────────────────
335
336impl 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// ─── Tests ────────────────────────────────────────────────────────────────────
343
344#[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}