Skip to main content

dnslib/vendors/unifi/
service.rs

1//! UniFi implementations of the vendor-neutral DNS service traits.
2//!
3//! UniFi DNS policies are site-scoped, not zone-scoped, so dnsync derives
4//! logical zones by suffix matching. The integration exposes:
5//!   - `list_records`  → GET /sites/{siteId}/dns/policies (paginated)
6//!   - `add_record`    → POST /sites/{siteId}/dns/policies
7//!   - `delete_record` → list, match by domain+type+value, DELETE by id
8//!
9//! Zones, cache, access lists, stats, settings, and zone import/export are
10//! unsupported and return `Error::unsupported`. `FORWARD_DOMAIN` policies
11//! are surfaced as provider-specific metadata in listings but cannot be
12//! created or deleted through the record API.
13
14use serde_json::Value;
15use tracing::instrument;
16
17use crate::control_plane::config::VendorKind;
18use crate::core::dns::capabilities::VendorCapabilities;
19use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
20use crate::core::dns::names::domain_matches_zone;
21use crate::core::dns::records::RecordData;
22use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
23use crate::core::dns::service::{
24    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
25    RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
26};
27use crate::core::error::{Error, Result};
28
29use super::client::UnifiClient;
30use super::mapping::{
31    policy_matches_delete_params, policy_to_zone_record, record_data_to_unifi_body,
32};
33
34// ─── DnsVendor ────────────────────────────────────────────────────────────────
35
36impl DnsVendor for UnifiClient {
37    fn kind(&self) -> VendorKind {
38        VendorKind::Unifi
39    }
40
41    fn capabilities(&self) -> VendorCapabilities {
42        VendorCapabilities {
43            zones: false,
44            records: true,
45            cache: false,
46            access_lists: false,
47            // `get_settings` returns the controller's visible site list so
48            // users can discover their site name/UUID without leaving the CLI.
49            settings: true,
50            zone_import: false,
51            zone_export: false,
52            logs: false,
53        }
54    }
55}
56
57// ─── ZoneRead ─────────────────────────────────────────────────────────────────
58
59impl ZoneRead for UnifiClient {
60    /// UniFi exposes no zone abstraction — there is nothing to list. Returning
61    /// `unsupported` lets the trait surface that clearly rather than faking a
62    /// synthetic zone list.
63    async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
64        Err(Error::unsupported("UniFi", "zone listing"))
65    }
66
67    #[instrument(
68        skip(self, _options),
69        fields(vendor = "unifi", operation = "list_records")
70    )]
71    async fn list_records<'a>(
72        &'a self,
73        domain: &'a str,
74        zone: Option<&'a str>,
75        _options: ListRecordsOptions,
76    ) -> Result<ListRecordsResponse> {
77        // Resolve the site first so a misconfigured site name fails with the
78        // friendly site-not-found error instead of a misleading 404 from the
79        // DNS policy endpoint.
80        let site_id = self.resolve_site_id().await?.to_string();
81        let policies = self.list_all_dns_policies(None).await?;
82
83        let zone_label = zone
84            .map(ToOwned::to_owned)
85            .unwrap_or_else(|| domain.to_string());
86
87        let records: Vec<ZoneRecord> = policies
88            .iter()
89            .filter(|p| domain_matches_zone(&p.domain, &zone_label))
90            .map(|p| policy_to_zone_record(p, &zone_label))
91            .collect();
92
93        let zone_info = ZoneInfo {
94            id: Some(site_id),
95            name: zone_label,
96            zone_type: "UniFi/Site".to_string(),
97            disabled: false,
98            dnssec_status: None,
99        };
100
101        Ok(ListRecordsResponse::single(zone_info, records))
102    }
103}
104
105// ─── ZoneWrite (unsupported — UniFi has no zone model) ───────────────────────
106
107impl ZoneWrite for UnifiClient {
108    async fn create_zone<'a>(&'a self, _zone: &'a str, _zone_type: &'a str) -> Result<Value> {
109        Err(Error::unsupported("UniFi", "zone creation"))
110    }
111
112    async fn delete_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
113        Err(Error::unsupported("UniFi", "zone deletion"))
114    }
115
116    async fn enable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
117        Err(Error::unsupported("UniFi", "zone enable"))
118    }
119
120    async fn disable_zone<'a>(&'a self, _zone: &'a str) -> Result<Value> {
121        Err(Error::unsupported("UniFi", "zone disable"))
122    }
123}
124
125// ─── RecordWrite ──────────────────────────────────────────────────────────────
126
127impl RecordWrite for UnifiClient {
128    #[instrument(skip(self, record), fields(vendor = "unifi", operation = "add_record"))]
129    async fn add_record<'a>(
130        &'a self,
131        zone: &'a str,
132        domain: &'a str,
133        ttl: u32,
134        record: &'a RecordData,
135    ) -> Result<Value> {
136        let fqdn = resolve_fqdn(domain, zone);
137        let body = record_data_to_unifi_body(&fqdn, ttl, true, record)?;
138        let created = self.create_dns_policy(&body).await?;
139        serde_json::to_value(created)
140            .map_err(|e| Error::parse(format!("re-encoding UniFi create response: {e}")))
141    }
142
143    #[instrument(
144        skip(self, type_params),
145        fields(vendor = "unifi", operation = "delete_record")
146    )]
147    async fn delete_record<'a>(
148        &'a self,
149        zone: &'a str,
150        domain: &'a str,
151        type_params: &'a [(&'a str, String)],
152    ) -> Result<Value> {
153        let fqdn = resolve_fqdn(domain, zone);
154        let policies = self.list_all_dns_policies(None).await?;
155
156        let matched = policies
157            .iter()
158            .find(|p| policy_matches_delete_params(p, &fqdn, type_params))
159            .ok_or_else(|| Error::Api {
160                message: format!("no matching UniFi DNS policy found for '{fqdn}'"),
161            })?;
162
163        self.delete_dns_policy(&matched.id).await?;
164        Ok(serde_json::json!({
165            "id": matched.id,
166            "domain": matched.domain,
167            "type": matched.policy_type.as_str(),
168            "deleted": true,
169        }))
170    }
171}
172
173/// Resolve a relative or absolute name within a zone into a UniFi FQDN.
174///
175/// A name is treated as already-qualified when it is the zone itself or
176/// already sits below the zone (e.g. `"www.example.com"` inside zone
177/// `"example.com"`). Multi-label relative names like `"a.b"` are appended to
178/// the zone — UniFi DNS policies are flat FQDNs, so silently leaving a
179/// relative dotted name unqualified would target the wrong domain.
180fn resolve_fqdn(domain: &str, zone: &str) -> String {
181    if domain == "@" {
182        return zone.to_string();
183    }
184    let candidate = domain.trim_end_matches('.');
185    let zone_lower = zone.to_ascii_lowercase();
186    let cand_lower = candidate.to_ascii_lowercase();
187    if cand_lower == zone_lower || cand_lower.ends_with(&format!(".{zone_lower}")) {
188        candidate.to_string()
189    } else {
190        format!("{candidate}.{zone}")
191    }
192}
193
194// ─── Unsupported operations ───────────────────────────────────────────────────
195
196impl CacheRead for UnifiClient {
197    async fn list_cache<'a>(&'a self, _domain: &'a str) -> Result<Value> {
198        Err(Error::unsupported("UniFi", "cache listing"))
199    }
200}
201
202impl CacheWrite for UnifiClient {
203    async fn delete_cache_zone<'a>(&'a self, _domain: &'a str) -> Result<Value> {
204        Err(Error::unsupported("UniFi", "cache zone deletion"))
205    }
206
207    async fn flush_cache(&self) -> Result<Value> {
208        Err(Error::unsupported("UniFi", "cache flush"))
209    }
210}
211
212impl StatsRead for UnifiClient {
213    async fn get_stats<'a>(&'a self, _stats_type: &'a str) -> Result<Value> {
214        Err(Error::unsupported("UniFi", "stats"))
215    }
216}
217
218impl AccessListRead for UnifiClient {
219    async fn list_blocked(&self) -> Result<Value> {
220        Err(Error::unsupported("UniFi", "blocked list"))
221    }
222
223    async fn list_allowed(&self) -> Result<Value> {
224        Err(Error::unsupported("UniFi", "allowed list"))
225    }
226}
227
228impl AccessListWrite for UnifiClient {
229    async fn add_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
230        Err(Error::unsupported("UniFi", "add blocked"))
231    }
232
233    async fn delete_blocked<'a>(&'a self, _domain: &'a str) -> Result<Value> {
234        Err(Error::unsupported("UniFi", "delete blocked"))
235    }
236
237    async fn add_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
238        Err(Error::unsupported("UniFi", "add allowed"))
239    }
240
241    async fn delete_allowed<'a>(&'a self, _domain: &'a str) -> Result<Value> {
242        Err(Error::unsupported("UniFi", "delete allowed"))
243    }
244}
245
246impl ZoneImport for UnifiClient {
247    async fn import_zone_file<'a>(
248        &'a self,
249        _zone: &'a str,
250        _file_name: String,
251        _file_bytes: Vec<u8>,
252        _overwrite: bool,
253        _overwrite_zone: bool,
254        _overwrite_soa_serial: bool,
255    ) -> Result<Value> {
256        Err(Error::unsupported("UniFi", "zone import"))
257    }
258}
259
260impl ZoneExport for UnifiClient {
261    async fn export_zone_file<'a>(&'a self, _zone: &'a str) -> Result<String> {
262        Err(Error::unsupported("UniFi", "zone export"))
263    }
264}
265
266impl LogsRead for UnifiClient {
267    async fn get_logs(&self, _options: LogsOptions) -> Result<Vec<LogLine>> {
268        Err(Error::unsupported("UniFi", "logs"))
269    }
270}
271
272impl SettingsRead for UnifiClient {
273    /// Returns the list of UniFi sites accessible to this API key, plus the
274    /// configured site label and whether it resolves to a known site. Use
275    /// this to discover the human-readable site name to put in `org_id`.
276    #[instrument(skip(self), fields(vendor = "unifi", operation = "get_settings"))]
277    async fn get_settings(&self) -> Result<Value> {
278        let sites = self.list_all_sites().await?;
279        let configured = self.site();
280        let resolved = super::responses::match_site(&sites, configured).map(|s| s.id.clone());
281        Ok(serde_json::json!({
282            "configuredSite": configured,
283            "resolvedSiteId": resolved,
284            "sites": sites,
285        }))
286    }
287}
288
289// ─── Tests ────────────────────────────────────────────────────────────────────
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::core::secret::ApiToken;
295
296    fn make_client() -> UnifiClient {
297        UnifiClient::new(
298            "https://unifi.local/proxy/network/integration/v1".to_string(),
299            ApiToken::new("test-token"),
300            "11111111-1111-1111-1111-111111111111".to_string(),
301        )
302        .unwrap()
303    }
304
305    // ── kind / capabilities ──────────────────────────────────────────────────
306
307    #[test]
308    fn kind_returns_unifi() {
309        assert_eq!(make_client().kind(), VendorKind::Unifi);
310    }
311
312    #[test]
313    fn capabilities_advertise_records_and_settings() {
314        let caps = make_client().capabilities();
315        assert!(!caps.zones);
316        assert!(caps.records);
317        assert!(!caps.cache);
318        assert!(!caps.access_lists);
319        // `get_settings` exposes the site list for UUID discovery.
320        assert!(caps.settings);
321        assert!(!caps.zone_import);
322        assert!(!caps.zone_export);
323    }
324
325    // ── unsupported operations ───────────────────────────────────────────────
326
327    macro_rules! assert_unsupported {
328        ($call:expr) => {
329            match $call.await.unwrap_err() {
330                Error::Unsupported { vendor, .. } => assert_eq!(vendor, "UniFi"),
331                other => panic!("expected Unsupported, got {other:?}"),
332            }
333        };
334    }
335
336    #[tokio::test]
337    async fn list_zones_is_unsupported() {
338        assert_unsupported!(make_client().list_zones(0, 25));
339    }
340
341    #[tokio::test]
342    async fn create_zone_is_unsupported() {
343        assert_unsupported!(make_client().create_zone("example.com", "Primary"));
344    }
345
346    #[tokio::test]
347    async fn delete_zone_is_unsupported() {
348        assert_unsupported!(make_client().delete_zone("example.com"));
349    }
350
351    #[tokio::test]
352    async fn enable_zone_is_unsupported() {
353        assert_unsupported!(make_client().enable_zone("example.com"));
354    }
355
356    #[tokio::test]
357    async fn disable_zone_is_unsupported() {
358        assert_unsupported!(make_client().disable_zone("example.com"));
359    }
360
361    #[tokio::test]
362    async fn list_cache_is_unsupported() {
363        assert_unsupported!(make_client().list_cache("example.com"));
364    }
365
366    #[tokio::test]
367    async fn delete_cache_zone_is_unsupported() {
368        assert_unsupported!(make_client().delete_cache_zone("example.com"));
369    }
370
371    #[tokio::test]
372    async fn flush_cache_is_unsupported() {
373        assert_unsupported!(make_client().flush_cache());
374    }
375
376    #[tokio::test]
377    async fn get_stats_is_unsupported() {
378        assert_unsupported!(make_client().get_stats("last7days"));
379    }
380
381    #[tokio::test]
382    async fn list_blocked_is_unsupported() {
383        assert_unsupported!(make_client().list_blocked());
384    }
385
386    #[tokio::test]
387    async fn list_allowed_is_unsupported() {
388        assert_unsupported!(make_client().list_allowed());
389    }
390
391    #[tokio::test]
392    async fn add_blocked_is_unsupported() {
393        assert_unsupported!(make_client().add_blocked("evil.example.com"));
394    }
395
396    #[tokio::test]
397    async fn delete_blocked_is_unsupported() {
398        assert_unsupported!(make_client().delete_blocked("evil.example.com"));
399    }
400
401    #[tokio::test]
402    async fn add_allowed_is_unsupported() {
403        assert_unsupported!(make_client().add_allowed("ok.example.com"));
404    }
405
406    #[tokio::test]
407    async fn delete_allowed_is_unsupported() {
408        assert_unsupported!(make_client().delete_allowed("ok.example.com"));
409    }
410
411    #[tokio::test]
412    async fn import_zone_file_is_unsupported() {
413        assert_unsupported!(make_client().import_zone_file(
414            "example.com",
415            "zone.txt".to_string(),
416            vec![],
417            true,
418            false,
419            false,
420        ));
421    }
422
423    #[tokio::test]
424    async fn export_zone_file_is_unsupported() {
425        assert_unsupported!(make_client().export_zone_file("example.com"));
426    }
427
428    // ── resolve_fqdn ─────────────────────────────────────────────────────────
429
430    #[test]
431    fn at_resolves_to_zone() {
432        assert_eq!(resolve_fqdn("@", "example.com"), "example.com");
433    }
434
435    #[test]
436    fn relative_label_joins_with_zone() {
437        assert_eq!(resolve_fqdn("www", "example.com"), "www.example.com");
438    }
439
440    #[test]
441    fn absolute_fqdn_is_kept() {
442        assert_eq!(
443            resolve_fqdn("www.example.com", "example.com"),
444            "www.example.com"
445        );
446    }
447
448    #[test]
449    fn trailing_dot_is_stripped() {
450        assert_eq!(
451            resolve_fqdn("www.example.com.", "example.com"),
452            "www.example.com"
453        );
454    }
455
456    #[test]
457    fn relative_dotted_label_is_appended_to_zone() {
458        assert_eq!(resolve_fqdn("a.b", "example.com"), "a.b.example.com");
459    }
460
461    #[test]
462    fn unrelated_fqdn_is_still_appended_to_zone() {
463        // A name that is not under the zone is treated as relative and
464        // appended — UniFi has no concept of cross-zone references.
465        assert_eq!(
466            resolve_fqdn("other.net", "example.com"),
467            "other.net.example.com"
468        );
469    }
470
471    // ── add_record rejects unsupported types pre-flight ─────────────────────
472
473    #[tokio::test]
474    async fn add_record_rejects_unsupported_type_without_network_call() {
475        let client = make_client();
476        let err = client
477            .add_record(
478                "example.com",
479                "@",
480                300,
481                &RecordData::Ns {
482                    nameserver: "ns1.example.com".into(),
483                    glue: None,
484                },
485            )
486            .await
487            .unwrap_err();
488        // Should be Unsupported, not Network — mapping rejects before any HTTP.
489        assert!(matches!(
490            err,
491            Error::Unsupported {
492                vendor: "UniFi",
493                ..
494            }
495        ));
496    }
497}