1#[cfg(feature = "__dnssec")]
11pub mod dnssec;
12
13#[cfg(feature = "prometheus-metrics")]
14use std::net::SocketAddr;
15#[cfg(feature = "__tls")]
16use std::{ffi::OsStr, fs};
17use std::{
18 fmt,
19 fs::File,
20 io::Read,
21 net::{AddrParseError, Ipv4Addr, Ipv6Addr},
22 path::{Path, PathBuf},
23 str::FromStr,
24 sync::Arc,
25 time::Duration,
26};
27
28use cfg_if::cfg_if;
29use ipnet::IpNet;
30#[cfg(feature = "__tls")]
31use rustls::{
32 pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject},
33 server::ResolvesServerCert,
34 sign::{CertifiedKey, SingleCertAndKey},
35};
36use serde::de::{self, MapAccess, SeqAccess, Visitor};
37use serde::{self, Deserialize, Deserializer};
38
39#[cfg(feature = "__tls")]
40use hickory_proto::rustls::default_provider;
41use hickory_proto::{ProtoError, rr::Name};
42#[cfg(feature = "__dnssec")]
43use hickory_server::authority::DnssecAuthority;
44#[cfg(feature = "__dnssec")]
45use hickory_server::dnssec::NxProofKind;
46#[cfg(feature = "blocklist")]
47use hickory_server::store::blocklist::BlocklistAuthority;
48#[cfg(feature = "blocklist")]
49use hickory_server::store::blocklist::BlocklistConfig;
50use hickory_server::store::file::FileConfig;
51#[cfg(feature = "resolver")]
52use hickory_server::store::forwarder::ForwardAuthority;
53#[cfg(feature = "resolver")]
54use hickory_server::store::forwarder::ForwardConfig;
55#[cfg(feature = "recursor")]
56use hickory_server::store::recursor::RecursiveAuthority;
57#[cfg(feature = "recursor")]
58use hickory_server::store::recursor::RecursiveConfig;
59#[cfg(feature = "sqlite")]
60use hickory_server::store::sqlite::{SqliteAuthority, SqliteConfig};
61use hickory_server::{
62 ConfigError,
63 authority::{AuthorityObject, ZoneType},
64 store::file::FileAuthority,
65};
66use tracing::{debug, info, warn};
67
68#[cfg(feature = "prometheus-metrics")]
69mod prometheus_server;
70
71#[cfg(feature = "prometheus-metrics")]
72pub use prometheus_server::PrometheusServer;
73
74static DEFAULT_PATH: &str = "/var/named"; static DEFAULT_PORT: u16 = 53;
76static DEFAULT_TLS_PORT: u16 = 853;
77static DEFAULT_HTTPS_PORT: u16 = 443;
78static DEFAULT_QUIC_PORT: u16 = 853; static DEFAULT_H3_PORT: u16 = 443;
80static DEFAULT_TCP_REQUEST_TIMEOUT: u64 = 5;
81
82#[derive(Deserialize, Debug)]
84#[serde(deny_unknown_fields)]
85pub struct Config {
86 #[serde(default)]
88 listen_addrs_ipv4: Vec<String>,
89 #[serde(default)]
91 listen_addrs_ipv6: Vec<String>,
92 listen_port: Option<u16>,
94 tls_listen_port: Option<u16>,
96 https_listen_port: Option<u16>,
98 quic_listen_port: Option<u16>,
100 h3_listen_port: Option<u16>,
102 #[cfg(feature = "prometheus-metrics")]
104 prometheus_listen_addr: Option<SocketAddr>,
105 disable_tcp: Option<bool>,
107 disable_udp: Option<bool>,
109 disable_tls: Option<bool>,
111 disable_https: Option<bool>,
113 disable_quic: Option<bool>,
115 #[cfg(feature = "prometheus-metrics")]
117 disable_prometheus: Option<bool>,
118 tcp_request_timeout: Option<u64>,
120 log_level: Option<String>,
122 directory: Option<String>,
124 pub user: Option<String>,
129 pub group: Option<String>,
134 #[serde(default)]
136 #[serde(deserialize_with = "deserialize_with_file")]
137 zones: Vec<ZoneConfig>,
138 #[cfg(feature = "__tls")]
140 tls_cert: Option<TlsCertConfig>,
141 #[cfg(any(feature = "__https", feature = "__h3"))]
144 http_endpoint: Option<String>,
145 #[serde(default)]
147 deny_networks: Vec<IpNet>,
148 #[serde(default)]
150 allow_networks: Vec<IpNet>,
151}
152
153impl Config {
154 pub fn read_config(path: &Path) -> Result<Self, ConfigError> {
156 let mut file = File::open(path)?;
157 let mut toml = String::new();
158 file.read_to_string(&mut toml)?;
159 Self::from_toml(&toml)
160 }
161
162 pub fn from_toml(toml: &str) -> Result<Self, ConfigError> {
164 Ok(toml::from_str(toml)?)
165 }
166
167 pub fn listen_addrs_ipv4(&self) -> Result<Vec<Ipv4Addr>, AddrParseError> {
169 self.listen_addrs_ipv4.iter().map(|s| s.parse()).collect()
170 }
171
172 pub fn listen_addrs_ipv6(&self) -> Result<Vec<Ipv6Addr>, AddrParseError> {
174 self.listen_addrs_ipv6.iter().map(|s| s.parse()).collect()
175 }
176
177 pub fn listen_port(&self) -> u16 {
179 self.listen_port.unwrap_or(DEFAULT_PORT)
180 }
181
182 pub fn tls_listen_port(&self) -> u16 {
184 self.tls_listen_port.unwrap_or(DEFAULT_TLS_PORT)
185 }
186
187 pub fn https_listen_port(&self) -> u16 {
189 self.https_listen_port.unwrap_or(DEFAULT_HTTPS_PORT)
190 }
191
192 pub fn quic_listen_port(&self) -> u16 {
194 self.quic_listen_port.unwrap_or(DEFAULT_QUIC_PORT)
195 }
196
197 pub fn h3_listen_port(&self) -> u16 {
199 self.h3_listen_port.unwrap_or(DEFAULT_H3_PORT)
200 }
201
202 #[cfg(feature = "prometheus-metrics")]
204 pub fn prometheus_listen_addr(&self) -> SocketAddr {
205 self.prometheus_listen_addr
206 .unwrap_or(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 9000))
207 }
208
209 pub fn disable_tcp(&self) -> bool {
211 self.disable_tcp.unwrap_or_default()
212 }
213
214 pub fn disable_udp(&self) -> bool {
216 self.disable_udp.unwrap_or_default()
217 }
218
219 pub fn disable_tls(&self) -> bool {
221 self.disable_tls.unwrap_or_default()
222 }
223
224 pub fn disable_https(&self) -> bool {
226 self.disable_https.unwrap_or_default()
227 }
228
229 pub fn disable_quic(&self) -> bool {
231 self.disable_quic.unwrap_or_default()
232 }
233
234 #[cfg(feature = "prometheus-metrics")]
236 pub fn disable_prometheus(&self) -> bool {
237 self.disable_prometheus.unwrap_or_default()
238 }
239
240 pub fn tcp_request_timeout(&self) -> Duration {
242 Duration::from_secs(
243 self.tcp_request_timeout
244 .unwrap_or(DEFAULT_TCP_REQUEST_TIMEOUT),
245 )
246 }
247
248 pub fn log_level(&self) -> tracing::Level {
250 if let Some(level_str) = &self.log_level {
251 tracing::Level::from_str(level_str).unwrap_or(tracing::Level::INFO)
252 } else {
253 tracing::Level::INFO
254 }
255 }
256
257 pub fn directory(&self) -> &Path {
259 self.directory
260 .as_ref()
261 .map_or(Path::new(DEFAULT_PATH), Path::new)
262 }
263
264 pub fn zones(&self) -> &[ZoneConfig] {
266 &self.zones
267 }
268
269 pub fn tls_cert(&self) -> Option<&TlsCertConfig> {
271 cfg_if! {
272 if #[cfg(feature = "__tls")] {
273 self.tls_cert.as_ref()
274 } else {
275 None
276 }
277 }
278 }
279
280 #[cfg(any(feature = "__https", feature = "__h3"))]
282 pub fn http_endpoint(&self) -> &str {
283 self.http_endpoint
284 .as_deref()
285 .unwrap_or(hickory_proto::http::DEFAULT_DNS_QUERY_PATH)
286 }
287
288 pub fn deny_networks(&self) -> &[IpNet] {
290 &self.deny_networks
291 }
292
293 pub fn allow_networks(&self) -> &[IpNet] {
295 &self.allow_networks
296 }
297}
298
299#[derive(Deserialize, Debug)]
300struct ZoneConfigWithFile {
301 file: Option<PathBuf>,
302 #[serde(flatten)]
303 config: ZoneConfig,
304}
305
306fn deserialize_with_file<'de, D>(deserializer: D) -> Result<Vec<ZoneConfig>, D::Error>
307where
308 D: Deserializer<'de>,
309 D::Error: serde::de::Error,
310{
311 Vec::<ZoneConfigWithFile>::deserialize(deserializer)?
312 .into_iter()
313 .map(|ZoneConfigWithFile { file, mut config }| match file {
314 Some(file) => match &mut config.zone_type_config {
315 ZoneTypeConfig::Primary(server_config)
316 | ZoneTypeConfig::Secondary(server_config) => {
317 if server_config
318 .stores
319 .iter()
320 .any(|store| matches!(store, ServerStoreConfig::File(_)))
321 {
322 Err(<D::Error as serde::de::Error>::custom(
323 "having `file` and `[zones.store]` item with type `file` is ambiguous",
324 ))
325 } else {
326 let store = ServerStoreConfig::File(FileConfig {
327 zone_file_path: file,
328 });
329
330 if server_config.stores.len() == 1
331 && matches!(&server_config.stores[0], ServerStoreConfig::Default)
332 {
333 server_config.stores[0] = store;
334 } else {
335 server_config.stores.push(store);
336 }
337 Ok(config)
338 }
339 }
340 _ => Err(<D::Error as serde::de::Error>::custom(
341 "cannot use `file` on a zone that is not primary or secondary",
342 )),
343 },
344
345 _ => Ok(config),
346 })
347 .collect::<Result<Vec<_>, _>>()
348}
349
350#[derive(Deserialize, Debug)]
352pub struct ZoneConfig {
353 pub zone: String, #[serde(flatten)]
357 pub zone_type_config: ZoneTypeConfig,
358}
359
360impl ZoneConfig {
361 #[warn(clippy::wildcard_enum_match_arm)] pub async fn load(&self, zone_dir: &Path) -> Result<Vec<Arc<dyn AuthorityObject>>, String> {
363 debug!("loading zone with config: {self:#?}");
364
365 let zone_name = self
366 .zone()
367 .map_err(|err| format!("failed to read zone name: {err}"))?;
368 let zone_type = self.zone_type();
369
370 let mut authorities: Vec<Arc<dyn AuthorityObject>> = vec![];
373
374 #[cfg(feature = "blocklist")]
375 let handle_blocklist_store = |config| {
376 let zone_name = zone_name.clone();
377
378 async move {
379 Result::<Arc<dyn AuthorityObject>, String>::Ok(Arc::new(
380 BlocklistAuthority::try_from_config(
381 zone_name.clone(),
382 zone_type,
383 config,
384 Some(zone_dir),
385 )
386 .await?,
387 ))
388 }
389 };
390
391 match &self.zone_type_config {
392 ZoneTypeConfig::Primary(server_config) | ZoneTypeConfig::Secondary(server_config) => {
393 debug!(
394 "loading authorities for {zone_name} with stores {:?}",
395 server_config.stores
396 );
397
398 let is_axfr_allowed = server_config.is_axfr_allowed();
399 for store in &server_config.stores {
400 let authority: Arc<dyn AuthorityObject> = match store {
401 #[cfg(feature = "sqlite")]
402 ServerStoreConfig::Sqlite(config) => {
403 #[cfg_attr(not(feature = "__dnssec"), allow(unused_mut))]
404 let mut authority = SqliteAuthority::try_from_config(
405 zone_name.clone(),
406 zone_type,
407 is_axfr_allowed,
408 server_config.is_dnssec_enabled(),
409 Some(zone_dir),
410 config,
411 #[cfg(feature = "__dnssec")]
412 server_config.nx_proof_kind.clone(),
413 )
414 .await?;
415
416 #[cfg(feature = "__dnssec")]
417 server_config.load_keys(&mut authority, &zone_name).await?;
418 Arc::new(authority)
419 }
420
421 ServerStoreConfig::File(config) => {
422 #[cfg_attr(not(feature = "__dnssec"), allow(unused_mut))]
423 let mut authority = FileAuthority::try_from_config(
424 zone_name.clone(),
425 zone_type,
426 is_axfr_allowed,
427 Some(zone_dir),
428 config,
429 #[cfg(feature = "__dnssec")]
430 server_config.nx_proof_kind.clone(),
431 )?;
432
433 #[cfg(feature = "__dnssec")]
434 server_config.load_keys(&mut authority, &zone_name).await?;
435 Arc::new(authority)
436 }
437 _ => return empty_stores_error(),
438 };
439
440 authorities.push(authority);
441 }
442 }
443 ZoneTypeConfig::External { stores } => {
444 debug!(
445 "loading authorities for {zone_name} with stores {:?}",
446 stores
447 );
448
449 #[cfg_attr(
450 not(any(feature = "blocklist", feature = "resolver")),
451 allow(unreachable_code, unused_variables, clippy::never_loop)
452 )]
453 for store in stores {
454 let authority: Arc<dyn AuthorityObject> = match store {
455 #[cfg(feature = "blocklist")]
456 ExternalStoreConfig::Blocklist(config) => {
457 handle_blocklist_store(config).await?
458 }
459 #[cfg(feature = "resolver")]
460 ExternalStoreConfig::Forward(config) => {
461 let forwarder = ForwardAuthority::builder_tokio(config.clone())
462 .with_origin(zone_name.clone())
463 .build()?;
464
465 Arc::new(forwarder)
466 }
467 #[cfg(feature = "recursor")]
468 ExternalStoreConfig::Recursor(config) => {
469 let recursor = RecursiveAuthority::try_from_config(
470 zone_name.clone(),
471 zone_type,
472 config,
473 Some(zone_dir),
474 )
475 .await?;
476
477 Arc::new(recursor)
478 }
479 _ => return empty_stores_error(),
480 };
481
482 authorities.push(authority);
483 }
484 }
485 }
486
487 info!("zone successfully loaded: {}", self.zone()?);
488 Ok(authorities)
489 }
490
491 pub fn zone(&self) -> Result<Name, ProtoError> {
494 Name::parse(&self.zone, Some(&Name::new()))
495 }
496
497 pub fn zone_type(&self) -> ZoneType {
499 match &self.zone_type_config {
500 ZoneTypeConfig::Primary { .. } => ZoneType::Primary,
501 ZoneTypeConfig::Secondary { .. } => ZoneType::Secondary,
502 ZoneTypeConfig::External { .. } => ZoneType::External,
503 }
504 }
505}
506
507fn empty_stores_error<T>() -> Result<T, String> {
508 Result::Err("empty [[zones.stores]] in config".to_owned())
509}
510
511#[derive(Deserialize, Debug)]
512#[serde(tag = "zone_type")]
513#[serde(deny_unknown_fields)]
514pub enum ZoneTypeConfig {
516 Primary(ServerZoneConfig),
517 Secondary(ServerZoneConfig),
518 External {
519 #[serde(default = "store_config_default")]
525 #[serde(deserialize_with = "store_config_visitor")]
526 stores: Vec<ExternalStoreConfig>,
527 },
528}
529
530impl ZoneTypeConfig {
531 pub fn as_server(&self) -> Option<&ServerZoneConfig> {
532 match self {
533 Self::Primary(c) | Self::Secondary(c) => Some(c),
534 _ => None,
535 }
536 }
537}
538
539#[derive(Deserialize, Debug)]
540#[serde(deny_unknown_fields)]
541pub struct ServerZoneConfig {
542 pub allow_axfr: Option<bool>,
544 #[cfg(feature = "__dnssec")]
546 #[serde(default)]
547 pub keys: Vec<dnssec::KeyConfig>,
548 #[cfg(feature = "__dnssec")]
550 pub nx_proof_kind: Option<NxProofKind>,
551 #[serde(default = "store_config_default")]
557 #[serde(deserialize_with = "store_config_visitor")]
558 pub stores: Vec<ServerStoreConfig>,
559}
560
561impl ServerZoneConfig {
562 #[cfg(feature = "__dnssec")]
563 async fn load_keys(
564 &self,
565 authority: &mut impl DnssecAuthority<Lookup = impl Send + Sync + Sized + 'static>,
566 zone_name: &Name,
567 ) -> Result<(), String> {
568 if !self.is_dnssec_enabled() {
569 return Ok(());
570 }
571
572 for key_config in &self.keys {
573 key_config.load(authority, zone_name.clone()).await?;
574 }
575
576 info!("signing zone: {zone_name}");
577 authority
578 .secure_zone()
579 .await
580 .map_err(|err| format!("failed to sign zone {zone_name}: {err}"))?;
581
582 Ok(())
583 }
584
585 pub fn file(&self) -> Option<&Path> {
590 self.stores.iter().find_map(|store| match store {
591 ServerStoreConfig::File(file_config) => Some(&*file_config.zone_file_path),
592 #[cfg(feature = "sqlite")]
593 ServerStoreConfig::Sqlite(sqlite_config) => Some(&*sqlite_config.zone_file_path),
594 ServerStoreConfig::Default => None,
595 })
596 }
597
598 pub fn is_axfr_allowed(&self) -> bool {
600 self.allow_axfr.unwrap_or(false)
601 }
602
603 pub fn is_dnssec_enabled(&self) -> bool {
605 cfg_if! {
606 if #[cfg(feature = "__dnssec")] {
607 !self.keys.is_empty()
608 } else {
609 false
610 }
611 }
612 }
613}
614
615#[derive(Deserialize, Debug, Default)]
617#[serde(tag = "type")]
618#[serde(rename_all = "lowercase")]
619#[non_exhaustive]
620pub enum ServerStoreConfig {
621 File(FileConfig),
623 #[cfg(feature = "sqlite")]
625 Sqlite(SqliteConfig),
626 #[default]
628 Default,
629}
630
631#[allow(clippy::large_enum_variant)]
633#[derive(Deserialize, Debug, Default)]
634#[serde(rename_all = "lowercase", tag = "type")]
635#[non_exhaustive]
636pub enum ExternalStoreConfig {
637 #[cfg(feature = "blocklist")]
639 Blocklist(BlocklistConfig),
640 #[cfg(feature = "resolver")]
642 Forward(ForwardConfig),
643 #[cfg(feature = "recursor")]
645 Recursor(Box<RecursiveConfig>),
646 #[default]
648 Default,
649}
650
651fn store_config_default<S: Default>() -> Vec<S> {
653 vec![Default::default()]
654}
655
656fn store_config_visitor<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
661where
662 D: Deserializer<'de>,
663 T: Deserialize<'de>,
664{
665 struct MapOrSequence<T>(std::marker::PhantomData<T>);
666
667 impl<'de, T: Deserialize<'de>> Visitor<'de> for MapOrSequence<T> {
668 type Value = Vec<T>;
669
670 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
671 formatter.write_str("map or sequence")
672 }
673
674 fn visit_seq<S>(self, seq: S) -> Result<Vec<T>, S::Error>
675 where
676 S: SeqAccess<'de>,
677 {
678 Deserialize::deserialize(de::value::SeqAccessDeserializer::new(seq))
679 }
680
681 fn visit_map<M>(self, map: M) -> Result<Vec<T>, M::Error>
682 where
683 M: MapAccess<'de>,
684 {
685 match Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)) {
686 Ok(seq) => Ok(vec![seq]),
687 Err(e) => Err(e),
688 }
689 }
690 }
691
692 deserializer.deserialize_any(MapOrSequence::<T>(Default::default()))
693}
694
695#[derive(Deserialize, PartialEq, Eq, Debug)]
697#[serde(deny_unknown_fields)]
698#[non_exhaustive]
699pub struct TlsCertConfig {
700 pub path: PathBuf,
701 pub endpoint_name: Option<String>,
702 pub private_key: PathBuf,
703}
704
705#[cfg(feature = "__tls")]
706impl TlsCertConfig {
707 pub fn load(&self, zone_dir: &Path) -> Result<Arc<dyn ResolvesServerCert>, String> {
709 if self.path.extension().and_then(OsStr::to_str) != Some("pem") {
710 return Err(format!(
711 "unsupported certificate file format (expected `.pem` extension): {}",
712 self.path.display()
713 ));
714 }
715
716 let cert_path = zone_dir.join(&self.path);
717 info!(
718 "loading TLS PEM certificate chain from: {}",
719 cert_path.display()
720 );
721
722 let cert_chain = CertificateDer::pem_file_iter(&cert_path)
723 .map_err(|e| {
724 format!(
725 "failed to read cert chain from {}: {e}",
726 cert_path.display()
727 )
728 })?
729 .collect::<Result<Vec<_>, _>>()
730 .map_err(|e| {
731 format!(
732 "failed to parse cert chain from {}: {e}",
733 cert_path.display()
734 )
735 })?;
736
737 let key_extension = self.private_key.extension();
738 let key = if key_extension.is_some_and(|ext| ext == "pem") {
739 let key_path = zone_dir.join(&self.private_key);
740 info!("loading TLS PKCS8 key from PEM: {}", key_path.display());
741 PrivateKeyDer::from_pem_file(&key_path)
742 .map_err(|e| format!("failed to read key from {}: {e}", key_path.display()))?
743 } else if key_extension.is_some_and(|ext| ext == "der" || ext == "key") {
744 let key_path = zone_dir.join(&self.private_key);
745 info!("loading TLS PKCS8 key from DER: {}", key_path.display());
746
747 let buf =
748 fs::read(&key_path).map_err(|e| format!("error reading key from file: {e}"))?;
749 PrivateKeyDer::try_from(buf).map_err(|e| format!("error parsing key DER: {e}"))?
750 } else {
751 return Err(format!(
752 "unsupported private key file format (expected `.pem` or `.der` extension): {}",
753 self.private_key.display()
754 ));
755 };
756
757 let certified_key = CertifiedKey::from_der(cert_chain, key, &default_provider())
758 .map_err(|err| format!("failed to read certificate and keys: {err:?}"))?;
759
760 Ok(Arc::new(SingleCertAndKey::from(certified_key)))
761 }
762}
763
764#[cfg(all(test, any(feature = "resolver", feature = "recursor")))]
765mod tests {
766 use super::*;
767
768 #[cfg(feature = "recursor")]
769 #[test]
770 fn example_recursor_config() {
771 toml::from_str::<Config>(include_str!(
772 "../../tests/test-data/test_configs/example_recursor.toml"
773 ))
774 .unwrap();
775 }
776
777 #[cfg(feature = "resolver")]
778 #[test]
779 fn single_store_config_error_message() {
780 match toml::from_str::<Config>(
781 r#"[[zones]]
782 zone = "."
783 zone_type = "External"
784
785 [zones.stores]
786 ype = "forward""#,
787 ) {
788 Ok(val) => panic!("expected error value; got ok: {val:?}"),
789 Err(e) => assert!(e.to_string().contains("missing field `type`")),
790 }
791 }
792
793 #[cfg(feature = "resolver")]
794 #[test]
795 fn chained_store_config_error_message() {
796 match toml::from_str::<Config>(
797 r#"[[zones]]
798 zone = "."
799 zone_type = "External"
800
801 [[zones.stores]]
802 type = "forward"
803
804 [[zones.stores.name_servers]]
805 socket_addr = "8.8.8.8:53"
806 protocol = "udp"
807 trust_negative_responses = false
808
809 [[zones.stores]]
810 type = "forward"
811
812 [[zones.stores.name_servers]]
813 socket_addr = "1.1.1.1:53"
814 rotocol = "udp"
815 trust_negative_responses = false"#,
816 ) {
817 Ok(val) => panic!("expected error value; got ok: {val:?}"),
818 Err(e) => assert!(dbg!(e).to_string().contains("unknown field `rotocol`")),
819 }
820 }
821
822 #[cfg(feature = "resolver")]
823 #[test]
824 fn file_store_zone_file_path() {
825 match toml::from_str::<Config>(
826 r#"[[zones]]
827 zone = "localhost"
828 zone_type = "Primary"
829
830 [zones.stores]
831 type = "file"
832 zone_file_path = "default/localhost.zone""#,
833 ) {
834 Ok(val) => {
835 let ZoneTypeConfig::Primary(config) = &val.zones[0].zone_type_config else {
836 panic!("expected primary zone type");
837 };
838
839 assert_eq!(config.stores.len(), 1);
840 assert!(matches!(
841 &config.stores[0],
842 ServerStoreConfig::File(FileConfig { zone_file_path }) if zone_file_path == Path::new("default/localhost.zone"),
843 ));
844 }
845 Err(e) => panic!("expected successful parse: {e:?}"),
846 }
847 }
848}