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        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
110// ─── ZoneWrite ────────────────────────────────────────────────────────────────
111
112impl 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
130// ─── RecordWrite ──────────────────────────────────────────────────────────────
131
132impl 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
208// ─── CacheRead ────────────────────────────────────────────────────────────────
209
210impl 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
217// ─── CacheWrite ───────────────────────────────────────────────────────────────
218
219impl 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
230// ─── StatsRead ────────────────────────────────────────────────────────────────
231
232impl 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
245// ─── AccessListRead ───────────────────────────────────────────────────────────
246
247impl 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
261// ─── AccessListWrite ──────────────────────────────────────────────────────────
262
263impl 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
295// ─── ZoneImport / ZoneExport ──────────────────────────────────────────────────
296
297impl 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
317// ─── SettingsRead ─────────────────────────────────────────────────────────────
318
319impl 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
326// ─── LogsRead ─────────────────────────────────────────────────────────────────
327
328impl 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// ─── Tests ────────────────────────────────────────────────────────────────────
335
336#[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}