1use std::collections::HashSet;
12
13use serde_json::Value;
14use tracing::instrument;
15
16use crate::control_plane::config::VendorKind;
17use crate::core::dns::capabilities::VendorCapabilities;
18use crate::core::dns::records::RecordData;
19use crate::core::dns::responses::{ListRecordsResponse, ZoneInfo, ZoneRecord};
20use crate::core::dns::logs::{LogLine, LogsOptions, LogsRead};
21use crate::core::dns::service::{
22 AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
23 RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
24};
25use crate::core::error::{Error, Result};
26use crate::vendors::pangolin::client::PangolinClient;
27use crate::vendors::pangolin::mapping;
28use crate::vendors::pangolin::responses::PangolinDomain;
29
30impl PangolinClient {
33 async fn fetch_zone_records(
38 &self,
39 domain: &PangolinDomain,
40 name_filter: Option<&str>,
41 options: ListRecordsOptions,
42 ) -> Result<crate::core::dns::responses::ZoneRecords> {
43 use crate::core::dns::responses::ZoneRecords;
44
45 let records_data = self
46 .get(
47 &format!(
48 "/org/{}/domain/{}/dns-records",
49 self.org_id, domain.domain_id
50 ),
51 &[],
52 )
53 .await?;
54
55 let dns_records = mapping::parse_dns_records(&records_data)?;
56
57 let lookup_names = if options.use_local_ip {
58 dns_records
59 .iter()
60 .filter(|r| matches!(r.record_type.to_uppercase().as_str(), "A" | "AAAA"))
61 .map(|r| r.base_domain.clone())
62 .collect::<HashSet<_>>()
63 .into_iter()
64 .collect::<Vec<_>>()
65 } else {
66 Vec::new()
67 };
68 let resolved = mapping::resolve_local_candidates(&lookup_names).await;
69
70 let records: Vec<ZoneRecord> = dns_records
71 .iter()
72 .filter(|r| r.domain_id == domain.domain_id)
73 .filter(|r| {
74 name_filter
75 .map(|n| r.base_domain.eq_ignore_ascii_case(n))
76 .unwrap_or(true)
77 })
78 .map(|r| {
79 mapping::dns_record_to_zone_record(
80 r,
81 &domain.base_domain,
82 resolved
83 .get(&r.base_domain)
84 .map(Vec::as_slice)
85 .unwrap_or(&[]),
86 options.use_local_ip,
87 )
88 })
89 .collect();
90
91 let zone_info = ZoneInfo {
92 id: Some(domain.domain_id.clone()),
93 name: domain.base_domain.clone(),
94 zone_type: format!("Pangolin/{}", domain.domain_type),
95 disabled: domain.failed || !domain.verified,
96 dnssec_status: None,
97 };
98
99 Ok(ZoneRecords {
100 zone: zone_info,
101 records,
102 })
103 }
104}
105
106impl DnsVendor for PangolinClient {
109 fn kind(&self) -> VendorKind {
110 VendorKind::Pangolin
111 }
112
113 fn capabilities(&self) -> VendorCapabilities {
114 VendorCapabilities {
115 zones: true,
116 records: true,
117 cache: false,
118 access_lists: false,
119 settings: true,
120 zone_import: false,
121 zone_export: false,
122 logs: false,
123 }
124 }
125}
126
127impl ZoneRead for PangolinClient {
130 #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_zones"))]
131 async fn list_zones(&self, page: u32, per_page: u32) -> Result<Value> {
132 let limit = per_page.to_string();
133 let offset = ((page.saturating_sub(1)) * per_page).to_string();
134 self.get(
135 &format!("/org/{}/domains", self.org_id),
136 &[("limit", limit), ("offset", offset)],
137 )
138 .await
139 }
140
141 #[instrument(
142 skip(self, options),
143 fields(vendor = "pangolin", operation = "list_records")
144 )]
145 async fn list_records(
146 &self,
147 domain: &str,
148 zone: Option<&str>,
149 options: ListRecordsOptions,
150 ) -> Result<ListRecordsResponse> {
151 let domains_data = self
153 .get(
154 &format!("/org/{}/domains", self.org_id),
155 &[("limit", "1000".to_string()), ("offset", "0".to_string())],
156 )
157 .await?;
158 let domains = mapping::parse_domains(&domains_data)?;
159
160 if let Some(zone_name) = zone {
161 let matching = domains
163 .iter()
164 .find(|d| d.base_domain.eq_ignore_ascii_case(zone_name))
165 .ok_or_else(|| {
166 Error::api(format!("zone '{zone_name}' not found in Pangolin domains"))
167 })?;
168 let name_filter = if options.all_subdomains {
171 None
172 } else {
173 Some(domain)
174 };
175 let zone_records = self
176 .fetch_zone_records(matching, name_filter, options)
177 .await?;
178 Ok(ListRecordsResponse {
179 zones: vec![zone_records],
180 })
181 } else {
182 let mut all_zones = Vec::with_capacity(domains.len());
184 for domain_entry in &domains {
185 let zone_records = self.fetch_zone_records(domain_entry, None, options).await?;
186 all_zones.push(zone_records);
187 }
188 Ok(ListRecordsResponse { zones: all_zones })
189 }
190 }
191}
192
193impl ZoneWrite for PangolinClient {
196 #[instrument(skip(self), fields(vendor = "pangolin", operation = "create_zone"))]
197 async fn create_zone(&self, _zone: &str, _zone_type: &str) -> Result<Value> {
198 Err(Error::unsupported("Pangolin", "zone creation"))
199 }
200
201 #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_zone"))]
202 async fn delete_zone(&self, _zone: &str) -> Result<Value> {
203 Err(Error::unsupported("Pangolin", "zone deletion"))
204 }
205
206 #[instrument(skip(self), fields(vendor = "pangolin", operation = "enable_zone"))]
207 async fn enable_zone(&self, _zone: &str) -> Result<Value> {
208 Err(Error::unsupported("Pangolin", "zone enable"))
209 }
210
211 #[instrument(skip(self), fields(vendor = "pangolin", operation = "disable_zone"))]
212 async fn disable_zone(&self, _zone: &str) -> Result<Value> {
213 Err(Error::unsupported("Pangolin", "zone disable"))
214 }
215}
216
217impl RecordWrite for PangolinClient {
220 #[instrument(
221 skip(self, _record),
222 fields(vendor = "pangolin", operation = "add_record")
223 )]
224 async fn add_record(
225 &self,
226 _zone: &str,
227 _domain: &str,
228 _ttl: u32,
229 _record: &RecordData,
230 ) -> Result<Value> {
231 Err(Error::unsupported("Pangolin", "record add"))
232 }
233
234 #[instrument(
235 skip(self, _type_params),
236 fields(vendor = "pangolin", operation = "delete_record")
237 )]
238 async fn delete_record(
239 &self,
240 _zone: &str,
241 _domain: &str,
242 _type_params: &[(&str, String)],
243 ) -> Result<Value> {
244 Err(Error::unsupported("Pangolin", "record delete"))
245 }
246}
247
248impl CacheRead for PangolinClient {
251 #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_cache"))]
252 async fn list_cache(&self, _domain: &str) -> Result<Value> {
253 Err(Error::unsupported("Pangolin", "cache"))
254 }
255}
256
257impl CacheWrite for PangolinClient {
258 #[instrument(
259 skip(self),
260 fields(vendor = "pangolin", operation = "delete_cache_zone")
261 )]
262 async fn delete_cache_zone(&self, _domain: &str) -> Result<Value> {
263 Err(Error::unsupported("Pangolin", "cache"))
264 }
265
266 #[instrument(skip(self), fields(vendor = "pangolin", operation = "flush_cache"))]
267 async fn flush_cache(&self) -> Result<Value> {
268 Err(Error::unsupported("Pangolin", "cache"))
269 }
270}
271
272impl StatsRead for PangolinClient {
275 #[instrument(skip(self), fields(vendor = "pangolin", operation = "get_stats"))]
276 async fn get_stats(&self, _stats_type: &str) -> Result<Value> {
277 Err(Error::unsupported("Pangolin", "stats"))
278 }
279}
280
281impl AccessListRead for PangolinClient {
284 #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_blocked"))]
285 async fn list_blocked(&self) -> Result<Value> {
286 Err(Error::unsupported("Pangolin", "access lists"))
287 }
288
289 #[instrument(skip(self), fields(vendor = "pangolin", operation = "list_allowed"))]
290 async fn list_allowed(&self) -> Result<Value> {
291 Err(Error::unsupported("Pangolin", "access lists"))
292 }
293}
294
295impl AccessListWrite for PangolinClient {
296 #[instrument(skip(self), fields(vendor = "pangolin", operation = "add_blocked"))]
297 async fn add_blocked(&self, _domain: &str) -> Result<Value> {
298 Err(Error::unsupported("Pangolin", "access lists"))
299 }
300
301 #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_blocked"))]
302 async fn delete_blocked(&self, _domain: &str) -> Result<Value> {
303 Err(Error::unsupported("Pangolin", "access lists"))
304 }
305
306 #[instrument(skip(self), fields(vendor = "pangolin", operation = "add_allowed"))]
307 async fn add_allowed(&self, _domain: &str) -> Result<Value> {
308 Err(Error::unsupported("Pangolin", "access lists"))
309 }
310
311 #[instrument(skip(self), fields(vendor = "pangolin", operation = "delete_allowed"))]
312 async fn delete_allowed(&self, _domain: &str) -> Result<Value> {
313 Err(Error::unsupported("Pangolin", "access lists"))
314 }
315}
316
317impl ZoneImport for PangolinClient {
320 #[instrument(
321 skip(self, _file_bytes),
322 fields(vendor = "pangolin", operation = "import_zone_file")
323 )]
324 async fn import_zone_file(
325 &self,
326 _zone: &str,
327 _file_name: String,
328 _file_bytes: Vec<u8>,
329 _overwrite: bool,
330 _overwrite_zone: bool,
331 _overwrite_soa_serial: bool,
332 ) -> Result<Value> {
333 Err(Error::unsupported("Pangolin", "zone import"))
334 }
335}
336
337impl ZoneExport for PangolinClient {
338 async fn export_zone_file<'a>(&'a self, _zone: &'a str) -> Result<String> {
339 Err(Error::unsupported("Pangolin", "zone export"))
340 }
341}
342
343impl SettingsRead for PangolinClient {
346 #[instrument(skip(self), fields(vendor = "pangolin", operation = "get_settings"))]
350 async fn get_settings(&self) -> Result<Value> {
351 let data = self
352 .get(
353 "/orgs",
354 &[("limit", "1000".to_string()), ("offset", "0".to_string())],
355 )
356 .await?;
357 Ok(redact_org_keys(data))
358 }
359}
360
361impl LogsRead for PangolinClient {
362 async fn get_logs(&self, _: LogsOptions) -> Result<Vec<LogLine>> {
363 Err(Error::unsupported("Pangolin", "logs"))
364 }
365}
366
367fn redact_org_keys(mut data: Value) -> Value {
369 if let Some(orgs) = data.get_mut("orgs").and_then(|o| o.as_array_mut()) {
370 for org in orgs.iter_mut() {
371 if let Some(obj) = org.as_object_mut() {
372 obj.remove("sshCaPrivateKey");
373 obj.remove("sshCaPublicKey");
374 }
375 }
376 }
377 data
378}
379
380#[cfg(test)]
383mod tests {
384 use super::*;
385 use serde_json::json;
386
387 use crate::vendors::pangolin::mapping::{
388 dns_record_to_zone_record, extract_subdomain, parse_dns_records, parse_domains,
389 parse_resources, resource_to_zone_record,
390 };
391 use crate::vendors::pangolin::responses::{PangolinDnsRecord, PangolinResource};
392
393 #[test]
396 fn apex_returns_at() {
397 assert_eq!(extract_subdomain("app.hankin.io", "app.hankin.io"), "@");
398 }
399
400 #[test]
401 fn single_label_subdomain() {
402 assert_eq!(
403 extract_subdomain("grafana.app.hankin.io", "app.hankin.io"),
404 "grafana"
405 );
406 }
407
408 #[test]
409 fn multi_label_subdomain() {
410 assert_eq!(
411 extract_subdomain("a.b.app.hankin.io", "app.hankin.io"),
412 "a.b"
413 );
414 }
415
416 #[test]
417 fn case_insensitive_stripping() {
418 assert_eq!(
419 extract_subdomain("Grafana.App.Hankin.IO", "app.hankin.io"),
420 "Grafana"
421 );
422 }
423
424 #[test]
425 fn unrelated_domain_returned_as_is() {
426 assert_eq!(
427 extract_subdomain("other.example.com", "app.hankin.io"),
428 "other.example.com"
429 );
430 }
431
432 fn make_resource(
435 full_domain: &str,
436 http: bool,
437 protocol: &str,
438 enabled: bool,
439 ) -> PangolinResource {
440 PangolinResource {
441 resource_id: 1,
442 name: "Test".to_string(),
443 full_domain: full_domain.to_string(),
444 http,
445 protocol: protocol.to_string(),
446 enabled,
447 domain_id: "dom1".to_string(),
448 health: "healthy".to_string(),
449 targets: vec![],
450 sites: vec![],
451 }
452 }
453
454 #[test]
455 fn http_resource_maps_to_http_record_type() {
456 let r = make_resource("svc.app.hankin.io", true, "tcp", true);
457 let rec = resource_to_zone_record(&r, "app.hankin.io");
458 assert_eq!(rec.record_type, "HTTP");
459 assert_eq!(rec.name, "svc");
460 assert!(!rec.disabled);
461 }
462
463 #[test]
464 fn non_http_resource_uses_uppercased_protocol() {
465 let r = make_resource("vpn.app.hankin.io", false, "tcp", true);
466 let rec = resource_to_zone_record(&r, "app.hankin.io");
467 assert_eq!(rec.record_type, "TCP");
468 }
469
470 #[test]
471 fn disabled_resource_maps_to_disabled_record() {
472 let r = make_resource("off.app.hankin.io", true, "tcp", false);
473 let rec = resource_to_zone_record(&r, "app.hankin.io");
474 assert!(rec.disabled);
475 }
476
477 #[test]
478 fn record_data_contains_resource_fields() {
479 let r = make_resource("svc.app.hankin.io", true, "tcp", true);
480 let rec = resource_to_zone_record(&r, "app.hankin.io");
481 assert_eq!(rec.data["resourceId"], 1);
482 assert_eq!(rec.data["fullDomain"], "svc.app.hankin.io");
483 assert_eq!(rec.data["health"], "healthy");
484 }
485
486 #[test]
489 fn parses_domain_list() {
490 let data = json!({
491 "domains": [
492 {
493 "domainId": "y61yv7gv7qmn2js",
494 "baseDomain": "app.hankin.io",
495 "verified": true,
496 "type": "ns",
497 "failed": false,
498 "tries": 0,
499 "configManaged": false,
500 "certResolver": null,
501 "preferWildcardCert": false,
502 "errorMessage": null
503 }
504 ],
505 "pagination": { "total": "1", "limit": 1000, "offset": 0 }
506 });
507 let domains = parse_domains(&data).unwrap();
508 assert_eq!(domains.len(), 1);
509 assert_eq!(domains[0].domain_id, "y61yv7gv7qmn2js");
510 assert_eq!(domains[0].base_domain, "app.hankin.io");
511 assert_eq!(domains[0].domain_type, "ns");
512 assert!(domains[0].verified);
513 assert!(!domains[0].failed);
514 }
515
516 #[test]
517 fn missing_domains_key_returns_parse_error() {
518 let err = parse_domains(&json!({})).unwrap_err();
519 assert!(matches!(err, Error::Parse { ref context } if context.contains("domains")));
520 }
521
522 #[test]
525 fn parses_resource_list() {
526 let data = json!({
527 "resources": [
528 {
529 "resourceId": 13613,
530 "niceId": "granular-greater-naked-tailed-armadillo",
531 "name": "Grafana",
532 "ssl": true,
533 "fullDomain": "grafana.app.hankin.io",
534 "passwordId": null,
535 "sso": true,
536 "pincodeId": null,
537 "whitelist": false,
538 "http": true,
539 "protocol": "tcp",
540 "proxyPort": null,
541 "wildcard": false,
542 "enabled": true,
543 "domainId": "y61yv7gv7qmn2js",
544 "headerAuthId": null,
545 "health": "healthy",
546 "targets": [],
547 "sites": []
548 }
549 ],
550 "pagination": { "total": 1, "pageSize": 5, "page": 1 }
551 });
552 let resources = parse_resources(&data).unwrap();
553 assert_eq!(resources.len(), 1);
554 assert_eq!(resources[0].resource_id, 13613);
555 assert_eq!(resources[0].full_domain, "grafana.app.hankin.io");
556 assert_eq!(resources[0].domain_id, "y61yv7gv7qmn2js");
557 assert!(resources[0].http);
558 assert!(resources[0].enabled);
559 }
560
561 #[test]
562 fn missing_resources_key_returns_parse_error() {
563 let err = parse_resources(&json!({})).unwrap_err();
564 assert!(matches!(err, Error::Parse { ref context } if context.contains("resources")));
565 }
566
567 #[test]
570 fn parses_dns_records_array() {
571 let records = parse_dns_records(&json!([
572 {
573 "id": 18720,
574 "domainId": "y61yv7gv7qmn2js",
575 "recordType": "NS",
576 "baseDomain": "app.hankin.io",
577 "value": "ns1.pangolin-ns.net",
578 "verified": true
579 }
580 ]))
581 .unwrap();
582
583 assert_eq!(records.len(), 1);
584 assert_eq!(records[0].id, 18720);
585 assert_eq!(records[0].record_type, "NS");
586 assert_eq!(records[0].value, "ns1.pangolin-ns.net");
587 }
588
589 #[test]
590 fn missing_dns_records_array_returns_parse_error() {
591 let err = parse_dns_records(&json!({})).unwrap_err();
592 assert!(matches!(err, Error::Parse { ref context } if context.contains("DNS records")));
593 }
594
595 #[test]
596 fn ns_dns_record_maps_to_normalized_zone_record() {
597 let record = PangolinDnsRecord {
598 id: 18720,
599 domain_id: "y61yv7gv7qmn2js".to_string(),
600 record_type: "NS".to_string(),
601 base_domain: "app.hankin.io".to_string(),
602 value: "ns1.pangolin-ns.net".to_string(),
603 verified: true,
604 };
605
606 let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &[], false);
607
608 assert_eq!(zone_record.name, "@");
609 assert_eq!(zone_record.record_type, "NS");
610 assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
611 assert_eq!(zone_record.data["glue"], serde_json::Value::Null);
612 assert!(!zone_record.disabled);
613 }
614
615 #[test]
616 fn a_dns_record_maps_to_normalized_zone_record() {
617 let record = PangolinDnsRecord {
618 id: 11,
619 domain_id: "hankin".to_string(),
620 record_type: "A".to_string(),
621 base_domain: "*.hankin.io".to_string(),
622 value: "144.6.233.253".to_string(),
623 verified: true,
624 };
625
626 let zone_record = dns_record_to_zone_record(&record, "hankin.io", &[], false);
627
628 assert_eq!(zone_record.name, "*");
629 assert_eq!(zone_record.record_type, "A");
630 assert_eq!(zone_record.data["ipAddress"], "144.6.233.253");
631 }
632
633 #[test]
634 fn cname_dns_record_maps_to_normalized_zone_record() {
635 let record = PangolinDnsRecord {
636 id: 18724,
637 domain_id: "4u6jvem261kcg4k".to_string(),
638 record_type: "CNAME".to_string(),
639 base_domain: "_acme-challenge.huly.hankin.io".to_string(),
640 value: "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net".to_string(),
641 verified: true,
642 };
643
644 let zone_record = dns_record_to_zone_record(&record, "huly.hankin.io", &[], false);
645
646 assert_eq!(zone_record.name, "_acme-challenge");
647 assert_eq!(zone_record.record_type, "CNAME");
648 assert_eq!(
649 zone_record.data["cname"],
650 "_acme-challenge.4u6jvem261kcg4k.cname.pangolin-ns.net"
651 );
652 }
653
654 #[test]
655 fn local_ip_flag_prefers_local_ipv4_for_a_records() {
656 let record = PangolinDnsRecord {
657 id: 11,
658 domain_id: "hankin".to_string(),
659 record_type: "A".to_string(),
660 base_domain: "hankin.io".to_string(),
661 value: "144.6.233.253".to_string(),
662 verified: true,
663 };
664 let resolved = vec![
665 "144.6.233.253".parse().unwrap(),
666 "192.168.1.10".parse().unwrap(),
667 ];
668
669 let zone_record = dns_record_to_zone_record(&record, "hankin.io", &resolved, true);
670
671 assert_eq!(zone_record.data["ipAddress"], "192.168.1.10");
672 }
673
674 #[test]
675 fn local_ip_flag_does_not_override_ns_records() {
676 let record = PangolinDnsRecord {
677 id: 18720,
678 domain_id: "y61yv7gv7qmn2js".to_string(),
679 record_type: "NS".to_string(),
680 base_domain: "app.hankin.io".to_string(),
681 value: "ns1.pangolin-ns.net".to_string(),
682 verified: true,
683 };
684 let resolved = vec!["192.168.1.10".parse().unwrap()];
685
686 let zone_record = dns_record_to_zone_record(&record, "app.hankin.io", &resolved, true);
687
688 assert_eq!(zone_record.data["nameServer"], "ns1.pangolin-ns.net");
689 }
690
691 #[test]
694 fn ssh_keys_are_redacted() {
695 let data = json!({
696 "orgs": [
697 {
698 "orgId": "hankin-io",
699 "name": "Hankin.io",
700 "sshCaPrivateKey": "PRIVATE_KEY_DATA",
701 "sshCaPublicKey": "PUBLIC_KEY_DATA"
702 }
703 ]
704 });
705 let result = redact_org_keys(data);
706 let org = &result["orgs"][0];
707 assert!(org.get("sshCaPrivateKey").is_none());
708 assert!(org.get("sshCaPublicKey").is_none());
709 assert_eq!(org["orgId"], "hankin-io");
710 }
711
712 #[test]
713 fn redact_handles_missing_orgs_key_gracefully() {
714 let data = json!({ "other": "data" });
715 let result = redact_org_keys(data.clone());
716 assert_eq!(result, data);
717 }
718}