1use 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::records::RecordData;
21use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
22use crate::core::dns::service::{
23 AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
24 RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
25};
26use crate::core::error::{Error, Result};
27
28use super::client::UnifiClient;
29use super::mapping::{
30 domain_matches_zone, policy_matches_delete_params, policy_to_zone_record,
31 record_data_to_unifi_body,
32};
33
34impl 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 settings: true,
50 zone_import: false,
51 zone_export: false,
52 logs: false,
53 }
54 }
55}
56
57impl ZoneRead for UnifiClient {
60 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 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
105impl 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
125impl 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
173fn 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
194impl 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 #[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#[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 #[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 assert!(caps.settings);
321 assert!(!caps.zone_import);
322 assert!(!caps.zone_export);
323 }
324
325 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 #[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 assert_eq!(resolve_fqdn("other.net", "example.com"), "other.net.example.com");
466 }
467
468 #[tokio::test]
471 async fn add_record_rejects_unsupported_type_without_network_call() {
472 let client = make_client();
473 let err = client
474 .add_record(
475 "example.com",
476 "@",
477 300,
478 &RecordData::Ns {
479 nameserver: "ns1.example.com".into(),
480 glue: None,
481 },
482 )
483 .await
484 .unwrap_err();
485 assert!(matches!(
487 err,
488 Error::Unsupported {
489 vendor: "UniFi",
490 ..
491 }
492 ));
493 }
494}