From f07d8d198ab4b8f046d392f5a51308eca87cdde5 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:39:48 +0800 Subject: [PATCH] feat(dns): use structured static record configuration (#2812) * feat(dns): structure static record configuration and validation Replace zone-file record strings with name/type/TTL objects and an RDATA value or literal TXT segments. Keep the existing 20 supported RR types without adding per-type protocol messages or compatibility formats. Compile file and management input through one core parser before accepting a configuration. Validate names, type/data branches, TTL inheritance, RRset conflicts and complete RDATA consumption with field paths. Preserve editable input while sorting compiled RRsets by canonical data. Complete relative SVCB/HTTPS targets against the zone origin. Adapt native DNS consumption, generated protocol bindings and frontend configuration types to the same model. Automatic node records and DNS publication behavior retain their existing runtime boundaries. Validate with 98 core configuration tests, 10 native DNS unit/lifecycle tests, frontend type/build/roundtrip checks and the existing 55 UI tests. Browser/WASI core checks, workspace fmt and full Clippy pass. Root/TUN integration is not run because the existing container cannot load the host-built binary due to its older glibc. * fix(dns): reject malformed SVCB input before library parsing Hickory 0.25.2 assumes ALPN list decoding cannot fail. Empty members or malformed character data instead trigger its expect, now reachable from shared file and management configuration validation without enabling Magic DNS. Validate HTTPS/SVCB ALPN members after the same list unescaping before calling the library. Reject a lone double-quote parameter value before the adjacent library quote slicing can panic. Keep valid input tokens and escaped ALPN data unchanged without catching panics or upgrading the dependency. Add one concentrated product regression through TOML and management JSON configuration generation for invalid lists, escaped token errors, lone quotes and valid ALPN lists/escapes. Preserve the structured schema, TXT behavior and DNS runtime boundaries. Reproduce the dependency panics first, then pass the targeted regression and all 99 affected configuration tests. Workspace fmt and full Clippy with -D warnings pass. Reuse prior unaffected frontend, codegen, native lifecycle and platform checks. * fix(dns): reject unclosed quoted service parameters Require every quoted SVCB/HTTPS parameter to have an unescaped closing quote before the library parser runs. Apply the check to opaque keys as well as ALPN, preserving valid quoted values and literal backslashes. Malformed file and management input now returns its existing value path. Extend the concentrated product regression with unclosed opaque values, escaped closing quotes and valid quoted parameters. Compare supported CNAME escapes with the direct library parser and retain field errors for semicolon and parenthesis labels that Hickory itself rejects. Keep name tokenization and the structured configuration schema unchanged. Reproduce the accepted malformed value through management generation, then pass all 99 configuration tests, workspace fmt and full workspace Clippy with -D warnings. Reuse unaffected native, frontend and platform evidence; follow the published candidate's actual CI separately. --- docs/magic-dns-records.md | 176 +++-- easytier-core/Cargo.toml | 2 +- easytier-core/src/config/api_input.rs | 71 +- easytier-core/src/config/dns.rs | 649 ++++++++++++++++++ easytier-core/src/config/mod.rs | 1 + easytier-core/src/config/toml.rs | 3 + easytier-proto/build/main.rs | 1 + easytier-proto/proto/core_config.proto | 45 +- easytier-proto/src/core_config.rs | 6 + .../scripts/test-network-config.mjs | 9 +- .../frontend-lib/src/types/network.ts | 14 +- .../instance/dns_server/server_instance.rs | 107 ++- easytier/src/instance/dns_server/tests.rs | 21 +- easytier/src/instance/dns_server/zone.rs | 238 +------ 14 files changed, 1036 insertions(+), 307 deletions(-) create mode 100644 easytier-core/src/config/dns.rs diff --git a/docs/magic-dns-records.md b/docs/magic-dns-records.md index 3f2296c1..740ecf8f 100644 --- a/docs/magic-dns-records.md +++ b/docs/magic-dns-records.md @@ -1,7 +1,6 @@ # Local Magic DNS records -Configure records through a TOML file. Enable Magic DNS as before and add -static records using DNS zone file syntax: +Enable Magic DNS and configure static records in the same TOML file: ```toml [flags] @@ -14,25 +13,119 @@ domain = "et.net." origin = "et.net." ttl = 60 records = [ - "nas IN A 10.0.0.8", - "nas IN AAAA fd00::8", - "files IN CNAME nas", - 'info IN TXT "local records"', + { name = "nas", type = "A", value = "10.0.0.8" }, + { name = "nas", type = "AAAA", value = "fd00::8" }, + { name = "files", type = "CNAME", value = "nas" }, + { name = "info", type = "TXT", txt = { segments = ["local records"] } }, ] [[dns.zone]] origin = "example.test." -records = ["service 120 IN A 10.0.0.9"] +records = [ + { name = "service", type = "A", ttl = 120, value = "10.0.0.9" }, + { name = "@", type = "MX", value = "10 mail" }, + { name = "_sip._tcp", type = "SRV", value = "10 5 5060 sip" }, +] ``` -`domain` under `[dns]` sets the default Magic DNS domain, defaulting to `"et.net."` -when omitted, and supersedes the deprecated `flags.tld_dns_zone`. +`dns.domain` controls the automatically generated node names, defaults to +`"et.net."`, and supersedes the deprecated `flags.tld_dns_zone`. Automatically +generated records are not part of the editable static `records` array. -`origin` must be a non-root DNS name. Record names are relative to it; -absolute names must belong to that zone. -`ttl` supplies the default TTL in seconds, or zero when omitted. Individual -records may specify their own TTL. Zone file directives such as `$INCLUDE` are -not accepted. +## Record semantics + +Each record object represents one resource record. `name` and `type` are required. +The DNS class is always IN. The `value` contains only the type's RDATA, without +an owner, TTL, class or type prefix. For example, MX uses `"10 mail"` and SRV uses +`"10 5 5060 sip"`. Quoting and escaping inside these compound values follow the +DNS library's presentation syntax. Zone directives, comments, additional records +and unused trailing tokens are rejected. + +Supported types are A, AAAA, ANAME, CAA, CERT, CNAME, CSYNC, HINFO, HTTPS, MX, +NAPTR, NS, OPENPGPKEY, PTR, SOA, SRV, SSHFP, SVCB, TLSA and TXT. This input +support does not add DNSSEC, ANAME resolution or change automatic SOA behavior. + +`origin` must be a non-root DNS name. A trailing dot is optional on the zone +origin. Origins must be unique within one instance's configuration after +normalization. Record names without a trailing dot are relative to the origin; +`@` names the origin itself. Absolute owner names must belong to the zone. +Underscores and wildcard owner names retain their DNS meaning. + +Domain-name fields inside RDATA follow the same relative/absolute rules, including +`@` for the origin. Absolute targets may point outside the zone. `"."` is accepted +where the record type permits a root target. SOA's mailbox field uses DNS name +syntax, not `user@example.com`. Text fields such as TXT segments, CAA properties +and NAPTR regular expressions are not expanded as domain names. + +The optional `ttl` fields use seconds. A record without a TTL inherits its zone's +TTL, and a zone without a TTL defaults to zero. Explicit zero is preserved. +Records with the same owner and type form one RRset and must have the same +effective TTL. Multiple values are allowed except for the library's single-value +types such as CNAME, ANAME and SOA. Duplicate RDATA and CNAME conflicts are errors. +SOA records must use the zone origin. + +Input order, relative names and omitted TTLs are preserved when saving the +configuration. DNS answers do not promise RRset value order. Runtime comparison +uses parsed data, so rearranging an RRset or changing domain-name case does not +change its meaning. + +## TXT records + +TXT uses `txt.segments` instead of `value`: + +```toml +[[dns.zone.records]] +name = "info" +type = "TXT" +txt = { segments = ['quote" slash\ ;', "", "tail"] } +``` + +Each array element becomes one UTF-8 DNS character-string of at most 255 bytes. +There must be at least one segment; an empty segment is allowed. Segments are +ordered and are never split or combined automatically. To configure two TXT +records, add two record objects. + +Segments use only TOML or JSON escaping, with no second layer of DNS text +escaping. `value` and `txt` are mutually exclusive. TXT must use `txt`, and other +types must use `value`. + +## Configuration and management + +The file and management API share `core_config.DnsConfigRaw`, including the +record enum and the `value | txt` oneof. Management JSON uses the same field names +and transparent collection arrays: + +```json +{ + "dns": { + "zone": [{ + "origin": "example.test.", + "ttl": 60, + "records": [ + {"name": "nas", "type": "A", "value": "192.0.2.1"}, + {"name": "info", "type": "TXT", "txt": {"segments": ["first", "second"]}} + ] + }] + } +} +``` + +An omitted collection and an explicit empty array remain distinct in the input. +The collection wrappers are `RepeatedDnsZoneConfigRaw` and `RepeatedDnsRecordRaw`; +their protobuf `items` fields are transparent in file/management JSON. + +File input, management configuration generation and native DNS startup use the +same core compiler. Validation happens before a candidate configuration is +accepted and reports paths such as `dns.zone[0].records[1].value`. Compound +RDATA errors identify `value`, rather than separate MX/SRV subfields. Existing +management error responses carry these paths and the parser's explanation. + +The existing management operation replaces the complete configuration candidate; +records do not have IDs or individual CRUD operations. Concurrent full edits +can overwrite each other. This model does not add configuration CAS or DNS +publication acknowledgements. CLI configuration files use the TOML model above. + +## Runtime boundary Static records come from the local configuration of the EasyTier instance that wins the local Magic DNS server election. They coexist with automatically @@ -42,53 +135,10 @@ configuration, no static records are served, even when another local instance has some. Records are not distributed to other machines. Only the elected instance's configured zones are added to the system resolver -match list, after the default zone. Zones configured by other local instances -are neither served nor used for system name resolution. +match list, after the default zone. Configured zones are authoritative: a name +missing from a configured zone does not fall back to system DNS. Queries outside +those zones retain the existing forwarding behavior. -Configured zones are authoritative: a name missing from a configured zone does -not fall back to system DNS. Queries outside those zones retain the existing -system DNS forwarding behavior. This does not change Magic DNS activation, -the fixed DNS address, TUN requirements, or platform DNS setup. Systems without -automatic DNS setup must continue to configure their resolver manually. - -## Migration and configuration definitions - -The file and management API share `core_config.DnsConfigRaw`. `NetworkConfig.dns` -uses field 77. These DNS inputs were not released as a public string contract. -The local `UpdateDnsRecordRequest` is unchanged: static records stay local to the -server instance and are never carried over RPC. - -Optional arrays use reusable messages: `common.RepeatedString` for record strings and -`core_config.RepeatedDnsZoneConfigRaw` for zones. An absent wrapper means the array was -omitted; a present wrapper with no items means an explicit empty array. TOML still -uses `[[dns.zone]]`, `zone = []`, and `records = []`. The collection wrappers use -ordinary Serde with `serde(transparent)`. Both `DnsConfigRaw` and -`DnsZoneConfigRaw` use pbjson and delegate their collection fields to these -transparent wrappers. TOML and DNS management JSON both use `zone`, with no -field renaming: - -```json -{"dns":{"zone":[{"origin":"example.test.","ttl":0,"records":["nas IN A 192.0.2.1"]}]}} -``` - -The protobuf field numbers, field types and wrapper nesting define the binary -contract. The DNS root uses zone at tag 1 and domain at tag 2; each zone uses -origin 1, ttl 2 and records 3; collection wrappers use items at tag 1. A future -configuration generator can rename helper messages while preserving this layout. -Existing TOML and JSON field names remain part of their respective formats. - -`ConfigLoader::get_dns` and `set_dns` now use this Raw message directly. Native -DNS input is parsed directly into runtime records. Defaults, collection unwrapping, -domain normalization and record validation stay in the native DNS parser. Management -projections and browser form merging preserve the Raw collection presence. - -When replacing the legacy DNS runtime, replace the interim -`ConfigLoader::get_dns`/`set_dns` methods and their internal call sites as needed. -dns-policy removes the legacy `magic_dns.proto` along with the old DNS server; -its node RPC and zone distribution protocol replace this local transport. This -interim feature does not extend that transport. - -The existing `accept_dns` switch and this interim runtime's authoritative-zone -behavior remain in use for now. On migration, the full DNS implementation and -the shared definition own activation and fallthrough defaults; these temporary -behaviors are not additional compatibility requirements for that implementation. +This does not change Magic DNS activation, the fixed DNS address, TUN +requirements, platform DNS setup or node record RPC. Systems without automatic +DNS setup must continue to configure their resolver manually. diff --git a/easytier-core/Cargo.toml b/easytier-core/Cargo.toml index bd6fd10e..1c05f7af 100644 --- a/easytier-core/Cargo.toml +++ b/easytier-core/Cargo.toml @@ -34,7 +34,7 @@ cidr = { workspace = true, features = ["serde"] } crossbeam-utils.workspace = true dashmap.workspace = true bon.workspace = true -hickory-proto = { version = "0.25.2", default-features = false } +hickory-proto = { version = "0.25.2", default-features = false, features = ["text-parsing"] } easytier-proto = { workspace = true, features = ["core"] } futures = { workspace = true, features = ["std", "async-await"] } guarden.workspace = true diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index af8f5308..7d98f1d6 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -247,6 +247,9 @@ fn parse_peer_urls(peer_urls: &[String]) -> Result, anyhow::Erro impl NetworkConfigExt for NetworkConfig { #[allow(deprecated)] fn gen_config(&self) -> Result { + if let Some(dns) = &self.dns { + super::dns::compile_dns(dns)?; + } let cfg = TomlConfigLoader::default(); cfg.set_dns(self.dns.clone()); cfg.set_id( @@ -711,7 +714,7 @@ mod tests { "", "[dns]\n", "[dns]\ndomain = 'mesh.test.'\n", - "[[dns.zone]]\norigin = 'example.test.'\nttl = 0\nrecords = ['nas IN A 10.0.0.8', 'nas IN AAAA fd00::8']\n", + "[[dns.zone]]\norigin = 'example.test.'\nttl = 0\nrecords = [{ name = 'nas', type = 'A', value = '10.0.0.8' }, { name = 'nas', type = 'AAAA', value = 'fd00::8' }]\n", ] { let config = TomlConfigLoader::new_from_str(dns).unwrap(); let expected = config.get_dns(); @@ -745,7 +748,15 @@ mod tests { "[dns]\nzone = []\n", "[[dns.zone]]\norigin = 'example.test.'\n", "[[dns.zone]]\norigin = 'example.test.'\nttl = 0\nrecords = []\n", - "[[dns.zone]]\norigin = 'example.test.'\nrecords = ['@ IN A 192.0.2.1']\n", + "[[dns.zone]]\norigin = 'example.test.'\nrecords = [{ name = '@', type = 'A', value = '192.0.2.1' }]\n", + r#"[[dns.zone]] +origin = 'example.test.' +ttl = 60 +records = [ + { name = '@', type = 'MX', value = '10 mail' }, + { name = 'info', type = 'TXT', ttl = 0, txt = { segments = ['quote" slash\ ;', '', 'tail'] } }, +] +"#, ] { let config = TomlConfigLoader::new_from_str(input).unwrap(); let expected = config.get_dns(); @@ -812,6 +823,10 @@ mod tests { "[[dns.zone]]\norigin = 'example.test.'\nrecrods = []\n", "[[dns.zone]]\norigin = 'example.test.'\nrecords = {}\n", "[[dns.zone]]\norigin = 'example.test.'\nttl = -1\n", + "[[dns.zone]]\norigin = 'example.test.'\nrecords = [{ name = '@', type = 'A', value = '192.0.2.1', txt = { segments = ['x'] } }]\n", + "[[dns.zone]]\norigin = 'example.test.'\nrecords = [{ name = '@', type = 'UNKNOWN', value = 'x' }]\n", + "[[dns.zone]]\norigin = 'example.test.'\nrecords = [{ name = '@', type = 'TXT', value = 'x' }]\n", + "[[dns.zone]]\norigin = 'example.test.'\nrecords = ['@ IN A 192.0.2.1']\n", ] { assert!( TomlConfigLoader::new_from_str(input).is_err(), @@ -820,6 +835,58 @@ mod tests { } } + #[test] + fn dns_alpn_errors_use_file_and_management_field_paths() { + for kind in ["HTTPS", "SVCB"] { + for (value, valid) in [ + ("1 . alpn=,", false), + ("1 . alpn=h2,,h3", false), + (r#"1 . alpn="h2,,h3""#, false), + (r"1 . alpn=\@,h2", false), + (r"1 . alpn=\(h2", false), + (r"1 . alpn=\$TTL", false), + (r#"1 . "alpn=;h2""#, false), + (r#"1 . "alpn= h2""#, false), + (r#"1 . alpn=\"unterminated"#, false), + ("1 . alpn=\"", false), + ("1 . port=\"", false), + (r#"1 target key65400="unterminated"#, false), + (r#"1 target key65400="tail\""#, false), + ("1 target alpn=h2,h3 port=443", true), + (r#"1 target alpn="h2,h3" port=443"#, true), + (r#"1 target alpn="f\\\\oo\,bar,h2" port=443"#, true), + (r"1 target alpn=\, port=443", true), + ("1 target alpn=h2, port=443", true), + (r#"1 target key65400="complete""#, true), + (r#"1 target key65400="tail\\""#, true), + ] { + let network: NetworkConfig = serde_json::from_value(serde_json::json!({ + "networking_method": NetworkingMethod::Standalone as i32, + "dns": { "zone": [{ + "origin": "example.test.", + "records": [{ "name": "svc", "type": kind, "value": value }] + }] } + })) + .unwrap(); + let file = format!( + "[[dns.zone]]\norigin = 'example.test.'\nrecords = [\ + {{ name = 'svc', type = '{kind}', value = '{value}' }}]\n" + ); + for result in [network.gen_config(), TomlConfigLoader::new_from_str(&file)] { + if valid { + assert!(result.is_ok(), "{kind} {value}: {result:?}"); + } else { + let error = result.unwrap_err().to_string(); + assert!( + error.contains("dns.zone[0].records[0].value"), + "{kind} {value}: {error}" + ); + } + } + } + } + } + fn api_portal_config() -> manage::VpnPortalConfig { manage::VpnPortalConfig { enabled: None, diff --git a/easytier-core/src/config/dns.rs b/easytier-core/src/config/dns.rs new file mode 100644 index 00000000..7c7a1308 --- /dev/null +++ b/easytier-core/src/config/dns.rs @@ -0,0 +1,649 @@ +//! Shared compilation of file and management DNS input into validated RRsets. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, bail, ensure}; +use easytier_proto::core_config::{ + DnsConfigRaw, DnsRecordRaw, DnsRecordType, DnsZoneConfigRaw, dns_record_raw::Data, +}; +use hickory_proto::{ + rr::{ + LowerName, Name, RData, Record, RecordSet, RecordType, RrKey, + rdata::{HTTPS, SVCB, TXT}, + }, + serialize::{ + binary::{BinEncodable, BinEncoder, EncodeMode}, + txt::RDataParser, + }, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DnsZone { + pub origin: LowerName, + pub records: BTreeMap, +} + +pub fn parse_origin(origin: &str) -> anyhow::Result { + ensure!( + !origin.trim().is_empty(), + "DNS zone origin must not be empty" + ); + let mut name: Name = origin.parse().context("invalid DNS zone origin")?; + ensure!(!name.is_root(), "DNS zone origin must not be root"); + name.set_fqdn(true); + Ok(LowerName::new(&name)) +} + +/// Compile without rewriting the input: relative names and TTL presence remain +/// user intent, while the returned zones compare independently of record order. +pub fn compile_dns(config: &DnsConfigRaw) -> anyhow::Result> { + if let Some(domain) = &config.domain { + parse_origin(domain).map_err(|error| anyhow::anyhow!("dns.domain: {error:#}"))?; + } + let mut zones = BTreeMap::new(); + for (index, input) in config + .zone + .iter() + .flat_map(|zones| &zones.items) + .enumerate() + { + let path = format!("dns.zone[{index}]"); + let zone = compile_zone(input).map_err(|error| anyhow::anyhow!("{path}.{error:#}"))?; + ensure!( + !zones.contains_key(&zone.origin), + "{path}.origin: duplicate zone origin {}", + zone.origin + ); + zones.insert(zone.origin.clone(), zone); + } + Ok(zones) +} + +pub fn compile_zone(input: &DnsZoneConfigRaw) -> anyhow::Result { + let origin = parse_origin(&input.origin).context("origin")?; + let origin_name = Name::from(&origin); + let mut records = BTreeMap::, Record>)>::new(); + let mut owner_types = BTreeMap::>::new(); + for (index, record_input) in input + .records + .iter() + .flat_map(|records| &records.items) + .enumerate() + { + let path = format!("records[{index}]"); + let record = compile_record( + record_input, + &origin_name, + record_input.ttl.or(input.ttl).unwrap_or_default(), + ) + .map_err(|error| anyhow::anyhow!("{path}.{error:#}"))?; + let owner = LowerName::new(record.name()); + let record_type = record.record_type(); + let types = owner_types.entry(owner.clone()).or_default(); + ensure!( + types.is_empty() + || (!types.contains(&RecordType::CNAME) && record_type != RecordType::CNAME), + "{path}.type: CNAME cannot coexist with another record at {owner}" + ); + types.insert(record_type); + ensure!( + record_type != RecordType::SOA || owner == origin, + "{path}.name: SOA must be at the zone origin" + ); + let key = RrKey::new(owner, record_type); + let (ttl, values) = records + .entry(key) + .or_insert_with(|| (record.ttl(), BTreeMap::new())); + ensure!(*ttl == record.ttl(), "{path}.ttl: RRset TTLs must match"); + let data_field = if record_type == RecordType::TXT { + "txt" + } else { + "value" + }; + let mut bytes = Vec::new(); + let mut encoder = BinEncoder::with_mode(&mut bytes, EncodeMode::Signing); + encoder.set_canonical_names(true); + record + .data() + .emit(&mut encoder) + .with_context(|| format!("{path}.{data_field}"))?; + ensure!( + !values.contains_key(&bytes), + "{path}.{data_field}: duplicate RDATA" + ); + ensure!( + values.is_empty() + || !matches!( + record_type, + RecordType::CNAME | RecordType::ANAME | RecordType::SOA + ), + "{path}.{data_field}: {record_type} permits only one value" + ); + values.insert(bytes, record); + } + Ok(DnsZone { + origin, + records: records + .into_iter() + .map(|(key, (_, values))| { + let mut records = values.into_values(); + let first = records.next().expect("each RRset has a record"); + let mut set = RecordSet::new(first.name().clone(), first.record_type(), 0); + set.insert(first, 0); + for record in records { + set.insert(record, 0); + } + (key, set) + }) + .collect(), + }) +} + +fn compile_record(input: &DnsRecordRaw, origin: &Name, ttl: u32) -> anyhow::Result { + ensure!( + !input.name.is_empty(), + "name: record name must not be empty" + ); + let name = if input.name == "@" { + origin.clone() + } else { + Name::parse(&input.name, Some(origin)).context("name")? + } + .to_lowercase(); + ensure!(origin.zone_of(&name), "name: record is outside its zone"); + let kind = DnsRecordType::try_from(input.r#type).context("type: unsupported record type")?; + ensure!( + kind != DnsRecordType::Unspecified, + "type: record type is required" + ); + let record_type: RecordType = kind.as_str_name().parse().context("type")?; + let rdata = match (&input.data, record_type) { + (Some(Data::Txt(txt)), RecordType::TXT) => { + ensure!( + !txt.segments.is_empty(), + "txt.segments: at least one segment is required" + ); + for (index, segment) in txt.segments.iter().enumerate() { + ensure!( + segment.len() <= 255, + "txt.segments[{index}]: exceeds 255 UTF-8 bytes" + ); + } + RData::TXT(TXT::new(txt.segments.clone())) + } + (Some(Data::Value(value)), kind) if kind != RecordType::TXT => { + parse_value(kind, value, origin).context("value")? + } + (Some(Data::Txt(_)), _) => bail!("txt: only TXT records accept txt.segments"), + (Some(Data::Value(_)), _) => bail!("value: TXT records require txt.segments"), + (None, RecordType::TXT) => bail!("txt: at least one segment is required"), + (None, _) => bail!("value: RDATA is required"), + }; + let record = Record::from_rdata(name, ttl, rdata); + record.to_bytes().with_context(|| { + if record_type == RecordType::TXT { + "txt.segments: invalid DNS wire encoding" + } else { + "value: invalid DNS wire encoding" + } + })?; + Ok(record) +} + +fn parse_value(kind: RecordType, value: &str, origin: &Name) -> anyhow::Result { + let mut tokens = rdata_tokens(value)?; + let name_fields: &[usize] = match kind { + RecordType::ANAME | RecordType::CNAME | RecordType::NS | RecordType::PTR => &[0], + RecordType::MX | RecordType::SVCB | RecordType::HTTPS => &[1], + RecordType::NAPTR => &[5], + RecordType::SOA => &[0, 1], + RecordType::SRV => &[3], + _ => &[], + }; + for &index in name_fields { + if let Some(token) = tokens.get_mut(index) + && token == "@" + { + *token = origin.to_string(); + } + } + if matches!(kind, RecordType::SVCB | RecordType::HTTPS) { + validate_svcb_params(&tokens)?; + } + let mut tokens = tokens.iter().map(String::as_str); + let mut data = RData::parse(kind, &mut tokens, Some(origin))?; + ensure!(tokens.next().is_none(), "unexpected trailing RDATA tokens"); + // Hickory 0.25's SVCB parser does not accept an origin. Complete only its + // DNS target, leaving parameter strings and opaque values untouched. + let complete = |data: &SVCB| -> anyhow::Result { + let target = if data.target_name().is_fqdn() { + data.target_name().clone() + } else { + data.target_name().clone().append_domain(origin)? + }; + Ok(SVCB::new( + data.svc_priority(), + target, + data.svc_params().to_vec(), + )) + }; + match &data { + RData::SVCB(svcb) => data = RData::SVCB(complete(svcb)?), + RData::HTTPS(https) => data = RData::HTTPS(HTTPS(complete(https)?)), + _ => {} + } + Ok(data) +} + +// Hickory 0.25.2 slices quoted values and expects ALPN decoding to be infallible. +// Reject malformed quotes and list members before entering those parser paths. +fn validate_svcb_params(tokens: &[String]) -> anyhow::Result<()> { + for token in tokens.iter().skip(2) { + let Some((key, value)) = token.split_once('=') else { + continue; + }; + let value = if let Some(value) = value.strip_prefix('"') { + let value = value + .strip_suffix('"') + .context("unclosed SVCB parameter quote")?; + ensure!( + value.chars().rev().take_while(|&ch| ch == '\\').count() % 2 == 0, + "unclosed SVCB parameter quote" + ); + value + } else { + value + }; + if key != "alpn" { + continue; + } + let mut chars = value.chars(); + let mut member = String::new(); + while let Some(ch) = chars.next() { + match ch { + '\\' => { + if let Some(ch) = chars.next() { + member.push(ch); + } + } + ',' => { + validate_alpn_member(&member)?; + member.clear(); + } + ch => member.push(ch), + } + } + if !member.is_empty() { + validate_alpn_member(&member)?; + } + } + Ok(()) +} + +fn validate_alpn_member(value: &str) -> anyhow::Result<()> { + // The library expects its first lexer token to be character data, rather + // than a leading blank, @, or parenthesized list. + ensure!( + value + .chars() + .next() + .is_some_and(|ch| !ch.is_whitespace() && !matches!(ch, '@' | '(')), + "invalid ALPN protocol identifier" + ); + rdata_tokens(value).context("invalid ALPN protocol identifier")?; + Ok(()) +} + +/// Tokenize one RDATA, retaining the iterator for strict consumption checks. +/// Hickory's lexer is private; its TXT parser decodes individual quoted strings +/// using the same escaping rules as the previous zone-file input. +fn rdata_tokens(value: &str) -> anyhow::Result> { + let mut chars = value.char_indices().peekable(); + let mut tokens = Vec::new(); + let mut list = false; + while let Some((start, ch)) = chars.next() { + match ch { + '(' => { + ensure!(!list, "nested RDATA parentheses are not supported"); + list = true; + } + ')' => { + ensure!(list, "unmatched RDATA parenthesis"); + list = false; + } + '\r' | '\n' => ensure!(list, "multiple RDATA lines are not supported"), + ch if ch.is_whitespace() => {} + ';' | '$' => bail!("zone directives and comments are not supported in RDATA"), + '"' => { + let mut end = None; + let mut steps = 0; + while let Some((index, ch)) = chars.next() { + if ch == '\\' { + let (_, escaped) = chars.next().context("incomplete RDATA escape")?; + if escaped.is_numeric() { + ensure!( + chars.next().is_some() && chars.next().is_some(), + "incomplete numeric RDATA escape" + ); + } + steps += 1; + continue; + } + if ch == '"' { + end = Some(index + 1); + break; + } + steps += 1; + } + let end = end.context("unclosed RDATA quoted string")?; + let token = &value[start..end]; + // The dependency's lexer asserts after 4096 steps per token. + // Values that exceed that bound must fail rather than panic. + ensure!(steps <= 4093, "RDATA token exceeds parser limit"); + // A leading blank enters the same lexer state as RDATA after + // a zone-file header, including its maximum token length. + let RData::TXT(decoded) = + RData::try_from_str(RecordType::TXT, &format!(" {token}"))? + else { + unreachable!("the TXT parser returns TXT"); + }; + for segment in decoded.txt_data() { + tokens.push(String::from_utf8(segment.to_vec())?); + } + } + ch => { + ensure!(!ch.is_control(), "control character in RDATA"); + let mut end = start + ch.len_utf8(); + while let Some(&(index, ch)) = chars.peek() { + if ch.is_whitespace() || matches!(ch, ')' | ';') { + break; + } + ensure!(!ch.is_control(), "control character in RDATA"); + chars.next(); + end = index + ch.len_utf8(); + } + let token = &value[start..end]; + ensure!( + token.chars().count() <= 4093, + "RDATA token exceeds parser limit" + ); + tokens.push(token.to_owned()); + } + } + } + ensure!(!list, "unclosed RDATA parenthesis"); + Ok(tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + use easytier_proto::core_config::DnsTxtData; + use hickory_proto::serialize::binary::BinDecodable; + + fn record(kind: DnsRecordType, value: &str) -> DnsRecordRaw { + DnsRecordRaw { + name: "service".into(), + r#type: kind as i32, + ttl: None, + data: Some(Data::Value(value.into())), + } + } + + fn zone(records: Vec) -> DnsZoneConfigRaw { + DnsZoneConfigRaw { + origin: "EXAMPLE.test".into(), + ttl: Some(60), + records: Some(records.into()), + } + } + + #[test] + fn supported_rdata_compiles_and_survives_wire_encoding() { + use DnsRecordType::*; + for (kind, value) in [ + (A, "192.0.2.1"), + (Aaaa, "2001:db8::1"), + (Aname, "target"), + (Caa, "0 issue \"letsencrypt.org\""), + (Cert, "1 123 3 Q2VydA=="), + (Cname, "target"), + (Csync, "1 3 A AAAA"), + (Hinfo, "\"cpu\" \"os\""), + (Https, "1 target alpn=h2 port=443"), + (Mx, "10 mail"), + ( + Naptr, + "100 10 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@example.test!\" target", + ), + (Ns, "ns"), + (Openpgpkey, "Zm9v"), + (Ptr, "target"), + (Soa, "ns hostmaster ( 1 2 3 4 5 )"), + (Srv, "10 5 5060 sip"), + (Sshfp, "1 1 ABCD"), + (Svcb, "1 target alpn=h2 port=443"), + (Tlsa, "3 1 1 ABCD"), + ] { + let mut input = record(kind, value); + if kind == Soa { + input.name = "@".into(); + } + let zone = compile_zone(&zone(vec![input])).unwrap_or_else(|error| { + panic!("{kind:?}: {error:#}"); + }); + let record = zone + .records + .values() + .next() + .unwrap() + .records_without_rrsigs() + .next() + .unwrap(); + let decoded = Record::from_bytes(&record.to_bytes().unwrap()).unwrap(); + assert_eq!(&decoded, record, "{kind:?}"); + assert_eq!(record.record_type(), (kind as u16).into()); + assert_eq!(record.ttl(), 60); + match record.data() { + RData::CNAME(name) => assert_eq!(name.to_string(), "target.example.test."), + RData::SVCB(data) => { + assert_eq!(data.target_name().to_string(), "target.example.test.") + } + RData::HTTPS(data) => { + assert_eq!(data.target_name().to_string(), "target.example.test.") + } + _ => {} + } + } + } + + #[test] + fn txt_segments_are_literal_ordered_utf8_strings() { + let segments = vec!["quote\" slash\\ ;\n".into(), String::new(), "x".repeat(255)]; + let mut input = record(DnsRecordType::Txt, ""); + input.data = Some(Data::Txt(DnsTxtData { + segments: segments.clone(), + })); + let parsed = compile_zone(&zone(vec![input.clone()])).unwrap(); + let record = parsed + .records + .values() + .next() + .unwrap() + .records_without_rrsigs() + .next() + .unwrap(); + let decoded = Record::from_bytes(&record.to_bytes().unwrap()).unwrap(); + let RData::TXT(txt) = decoded.data() else { + panic!("expected TXT") + }; + assert_eq!( + txt.txt_data(), + segments + .iter() + .map(|s| s.as_bytes().to_vec().into_boxed_slice()) + .collect::>() + ); + for segments in [vec![], vec!["x".repeat(256)], vec!["é".repeat(128)]] { + input.data = Some(Data::Txt(DnsTxtData { segments })); + assert!(compile_zone(&zone(vec![input.clone()])).is_err()); + } + } + + #[test] + fn names_defaults_and_rrsets_share_one_semantics() { + let mut a = record(DnsRecordType::A, "192.0.2.1"); + a.name = "HOST".into(); + let mut b = record(DnsRecordType::A, "192.0.2.2"); + b.name = "host.example.test.".into(); + let input = zone(vec![a.clone(), b.clone()]); + let before = input.clone(); + let first = compile_zone(&input).unwrap(); + assert_eq!(input, before); + assert_eq!( + first, + compile_zone(&zone(vec![b.clone(), a.clone()])).unwrap() + ); + b.ttl = Some(0); + assert!( + compile_zone(&zone(vec![a.clone(), b])) + .unwrap_err() + .to_string() + .contains("records[1].ttl") + ); + assert!(compile_zone(&zone(vec![a.clone(), a])).is_err()); + let mut input = record(DnsRecordType::Cname, "@"); + input.name = "_service._tcp".into(); + input.ttl = Some(0); + let parsed = compile_zone(&zone(vec![input])).unwrap(); + let rr = parsed + .records + .values() + .next() + .unwrap() + .records_without_rrsigs() + .next() + .unwrap(); + assert_eq!(rr.name().to_string(), "_service._tcp.example.test."); + assert_eq!(rr.ttl(), 0); + assert_eq!(rr.data().to_string(), "example.test."); + for value in ["external.invalid.", "."] { + let input = record(DnsRecordType::Srv, &format!("0 0 0 {value}")); + assert!(compile_zone(&zone(vec![input])).is_ok()); + } + let mut wildcard = record(DnsRecordType::A, "192.0.2.1"); + wildcard.name = "*.example.test.".into(); + let mut input = zone(vec![wildcard]); + input.ttl = None; + let parsed = compile_zone(&input).unwrap(); + assert_eq!(parsed.records.values().next().unwrap().ttl(), 0); + assert!( + compile_zone(&zone(vec![ + record(DnsRecordType::A, "192.0.2.1"), + record(DnsRecordType::Cname, "target"), + ])) + .is_err() + ); + } + + #[test] + fn rdata_token_boundaries_preserve_existing_library_data() { + use hickory_proto::serialize::txt::Parser; + let origin: Name = "example.test.".parse().unwrap(); + for value in [ + format!("0 custom \"{}\"", "a".repeat(4092)), + format!("0 \"custom\"\"{}\"", "a".repeat(4093)), + format!("0 custom \"{}\"", "\\a".repeat(3000)), + r#"0 custom "a\077b""#.into(), + ] { + let content = format!("$ORIGIN example.test.\n$TTL 60\nservice IN CAA {value}\n"); + let (_, existing) = Parser::new(content, None, Some(origin.clone())) + .parse() + .unwrap(); + let parsed = compile_zone(&zone(vec![record(DnsRecordType::Caa, &value)])).unwrap(); + assert_eq!(parsed.records, existing); + } + let input = record( + DnsRecordType::Caa, + &format!("0 custom \"{}\"", "a".repeat(4094)), + ); + assert!(compile_zone(&zone(vec![input])).is_err()); + for target in [r"host\065label.example.test.", r"host\-label.example.test."] { + let expected = + RData::parse(RecordType::CNAME, [target].into_iter(), Some(&origin)).unwrap(); + let parsed = compile_zone(&zone(vec![record(DnsRecordType::Cname, target)])).unwrap(); + let actual = parsed + .records + .values() + .next() + .unwrap() + .records_without_rrsigs() + .next() + .unwrap() + .data(); + assert_eq!(actual, &expected); + } + } + + #[test] + fn invalid_input_fails_with_record_field_paths() { + for (kind, value) in [ + (DnsRecordType::A, "192.0.2.1 extra"), + (DnsRecordType::A, "host 60 IN A 192.0.2.1"), + (DnsRecordType::A, "192.0.2.1\nother IN A 192.0.2.2"), + (DnsRecordType::A, "(192.0.2.1 other IN A 192.0.2.2)"), + (DnsRecordType::A, "$ORIGIN other.test."), + (DnsRecordType::A, "192.0.2.1 ; ignored"), + (DnsRecordType::A, "\"unclosed"), + (DnsRecordType::A, "("), + (DnsRecordType::A, ")"), + (DnsRecordType::Aaaa, "192.0.2.1"), + (DnsRecordType::Mx, "65536 target"), + (DnsRecordType::Hinfo, "\"cpu\" \"os\" extra"), + (DnsRecordType::Svcb, "1 target port=443 port=444"), + (DnsRecordType::Cname, r"host\;label.example.test."), + (DnsRecordType::Cname, r"host\)label.example.test."), + ] { + let input = DnsConfigRaw { + domain: None, + zone: Some(vec![zone(vec![record(kind, value)])].into()), + }; + let error = compile_dns(&input).unwrap_err().to_string(); + assert!(error.contains("dns.zone[0].records[0].value"), "{error}"); + } + for kind in [DnsRecordType::Unspecified as i32, 65535] { + let mut input = record(DnsRecordType::A, "192.0.2.1"); + input.r#type = kind; + assert!(compile_zone(&zone(vec![input])).is_err()); + } + for data in [ + None, + Some(Data::Txt(DnsTxtData { + segments: vec!["x".into()], + })), + ] { + let mut input = record(DnsRecordType::A, "192.0.2.1"); + input.data = data; + assert!(compile_zone(&zone(vec![input])).is_err()); + } + assert!(compile_zone(&zone(vec![record(DnsRecordType::Txt, "\"x\"")])).is_err()); + let mut outside = record(DnsRecordType::A, "192.0.2.1"); + outside.name = "other.invalid.".into(); + assert!( + compile_zone(&zone(vec![outside])) + .unwrap_err() + .to_string() + .contains(".name") + ); + let zones = DnsConfigRaw { + domain: None, + zone: Some(vec![zone(vec![]), zone(vec![])].into()), + }; + assert!( + compile_dns(&zones) + .unwrap_err() + .to_string() + .contains("dns.zone[1].origin") + ); + } +} diff --git a/easytier-core/src/config/mod.rs b/easytier-core/src/config/mod.rs index c784e875..2ba7f288 100644 --- a/easytier-core/src/config/mod.rs +++ b/easytier-core/src/config/mod.rs @@ -11,6 +11,7 @@ pub mod api_input; ))] mod browser; mod dhcp; +pub mod dns; mod encryption; pub mod gateway; pub mod peers; diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index 8e3c71db..b79ffd67 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -597,6 +597,9 @@ impl TomlConfig { } fn new_from_config(mut config: Config) -> Result { + if let Some(dns) = &config.dns { + super::dns::compile_dns(dns)?; + } config.secure_mode = config .secure_mode .take() diff --git a/easytier-proto/build/main.rs b/easytier-proto/build/main.rs index 871640fd..16a750cb 100644 --- a/easytier-proto/build/main.rs +++ b/easytier-proto/build/main.rs @@ -181,6 +181,7 @@ fn main() -> anyhow::Result<()> { let repeated_types = [ ".common.RepeatedString", ".core_config.RepeatedDnsZoneConfigRaw", + ".core_config.RepeatedDnsRecordRaw", ]; for ty in repeated_types { config diff --git a/easytier-proto/proto/core_config.proto b/easytier-proto/proto/core_config.proto index f58b70c8..44cac830 100644 --- a/easytier-proto/proto/core_config.proto +++ b/easytier-proto/proto/core_config.proto @@ -68,5 +68,48 @@ message RepeatedDnsZoneConfigRaw { repeated DnsZoneConfigRaw items = 1; } message DnsZoneConfigRaw { string origin = 1; optional uint32 ttl = 2; - common.RepeatedString records = 3; + RepeatedDnsRecordRaw records = 3; +} + +message RepeatedDnsRecordRaw { repeated DnsRecordRaw items = 1; } + +// The values are DNS RR type codes, restricted to the supported input types. +enum DnsRecordType { + DNS_RECORD_TYPE_UNSPECIFIED = 0; + A = 1; + NS = 2; + CNAME = 5; + SOA = 6; + PTR = 12; + HINFO = 13; + MX = 15; + TXT = 16; + AAAA = 28; + SRV = 33; + NAPTR = 35; + CERT = 37; + SSHFP = 44; + TLSA = 52; + CSYNC = 62; + SVCB = 64; + HTTPS = 65; + OPENPGPKEY = 61; + CAA = 257; + ANAME = 65305; +} + +message DnsRecordRaw { + string name = 1; + DnsRecordType type = 2; + optional uint32 ttl = 3; + oneof data { + // One RDATA in presentation format, without an owner, TTL, class or type. + string value = 4; + DnsTxtData txt = 5; + } +} + +message DnsTxtData { + // One UTF-8 character-string per segment, without DNS text escaping. + repeated string segments = 1; } diff --git a/easytier-proto/src/core_config.rs b/easytier-proto/src/core_config.rs index 52219299..e54e7c91 100644 --- a/easytier-proto/src/core_config.rs +++ b/easytier-proto/src/core_config.rs @@ -6,3 +6,9 @@ impl From> for RepeatedDnsZoneConfigRaw { Self { items } } } + +impl From> for RepeatedDnsRecordRaw { + fn from(items: Vec) -> Self { + Self { items } + } +} diff --git a/easytier-web/frontend-lib/scripts/test-network-config.mjs b/easytier-web/frontend-lib/scripts/test-network-config.mjs index f92e8319..881f81ba 100644 --- a/easytier-web/frontend-lib/scripts/test-network-config.mjs +++ b/easytier-web/frontend-lib/scripts/test-network-config.mjs @@ -172,7 +172,10 @@ function allFieldFixture() { dns: { zone: [{ origin: 'example.test.', ttl: 0, - records: ['nas IN A 192.0.2.1'], + records: [ + { name: 'nas', type: 'A', value: '192.0.2.1' }, + { name: 'info', type: 'TXT', ttl: 0, txt: { segments: ['quote" slash\\ ;', '', 'tail'] } }, + ], }] }, enable_private_mode: true, enable_quic_proxy: true, @@ -620,6 +623,10 @@ for (const dns of [ { zone: [] }, { domain: 'mesh.test.', zone: [{ origin: 'example.test.' }] }, { zone: [{ origin: 'example.test.', ttl: 0, records: [] }] }, + { zone: [{ origin: 'example.test.', records: [ + { name: '@', type: 'MX', value: '10 mail' }, + { name: 'info', type: 'TXT', txt: { segments: ['first', '', 'second'] } }, + ] }] }, ]) { const normalized = normalizeNetworkConfig({ ...DEFAULT_NETWORK_CONFIG(), dns }); const backend = toBackendNetworkConfig(normalized); diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index 86d51def..f7764ee0 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -9,6 +9,7 @@ import { type VpnPortalConfig, } from '../generated/proto/api_manage' import { ScalarType } from '@protobuf-ts/runtime' +import { DnsRecordType, type DnsRecordRaw } from '../generated/proto/core_config' import type { FlagMeta } from '../generated/proto/annotations' import { VpnPortalClientState, @@ -37,7 +38,7 @@ import { } from '../generated/proto/common' import { prepareNetworkConfigForProtoJson } from './networkCompat' -export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod } +export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, DnsRecordType, NatType, NetworkingMethod } export { VpnPortalClientState } export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig, VpnPortalClientConfig, VpnPortalClientInfo, VpnPortalConfig, VpnPortalInfo } @@ -57,11 +58,20 @@ export const PEER_PROTOCOLS: { [proto: string]: number } = { // Configuration JSON uses transparent repeated wrappers, as Rust Serde does. // Derive the value types from the protocol schema rather than copying fields. +type OneofJson = T extends { oneofKind: keyof T } + ? { [Field in Exclude]: ConfigJson } + : never + type ConfigJson = T extends { items: Array } ? ConfigJson[] - : T extends object ? { [Key in keyof T]: ConfigJson } : T + : T extends DnsRecordRaw + ? ConfigJson> + & { type: DnsRecordType | keyof typeof DnsRecordType } + & OneofJson + : T extends object ? { [Key in keyof T]: ConfigJson } : T export type DnsConfig = ConfigJson> +export type DnsRecord = ConfigJson export type NetworkConfig = Omit< ProtoNetworkConfig, diff --git a/easytier/src/instance/dns_server/server_instance.rs b/easytier/src/instance/dns_server/server_instance.rs index 81207078..dc538d0c 100644 --- a/easytier/src/instance/dns_server/server_instance.rs +++ b/easytier/src/instance/dns_server/server_instance.rs @@ -562,6 +562,9 @@ mod tests { use super::*; use crate::proto::api::instance::Route; use easytier_core::config::toml::{DnsConfig, DnsZoneConfig}; + use easytier_proto::core_config::{ + DnsRecordRaw, DnsRecordType, DnsTxtData, dns_record_raw::Data, + }; use hickory_proto::{ op::{Message, Query}, rr::{RData, RecordType}, @@ -587,7 +590,22 @@ mod tests { } } - fn config(zones: &[(&str, &[&str])]) -> DnsConfig { + fn record(name: &str, kind: DnsRecordType, value: &str, ttl: Option) -> DnsRecordRaw { + DnsRecordRaw { + name: name.into(), + r#type: kind as i32, + ttl, + data: Some(if kind == DnsRecordType::Txt { + Data::Txt(DnsTxtData { + segments: vec![value.into()], + }) + } else { + Data::Value(value.into()) + }), + } + } + + fn config(zones: &[(&str, &[DnsRecordRaw])]) -> DnsConfig { DnsConfig { domain: None, zone: Some( @@ -596,13 +614,7 @@ mod tests { .map(|(origin, records)| DnsZoneConfig { origin: (*origin).to_owned(), ttl: Some(60), - records: Some( - records - .iter() - .map(|record| (*record).to_owned()) - .collect::>() - .into(), - ), + records: Some(records.to_vec().into()), }) .collect::>() .into(), @@ -786,28 +798,31 @@ mod tests { ( "ET.NET", &[ - "nas IN A 10.0.0.8", - "nas IN A 10.0.0.18", - "nas IN AAAA fd00::8", - "nas IN AAAA fd00::18", - "alias 45 IN CNAME nas", - "chain 30 IN CNAME alias", - "node-alias IN CNAME node", - "nas.sub IN A 10.0.0.98", - "specific IN CNAME nas.sub", - "missing IN CNAME absent", - "cycle IN CNAME cycle.example.test.", - "info IN TXT \"local record\"", + record("nas", DnsRecordType::A, "10.0.0.8", None), + record("nas", DnsRecordType::A, "10.0.0.18", None), + record("nas", DnsRecordType::Aaaa, "fd00::8", None), + record("nas", DnsRecordType::Aaaa, "fd00::18", None), + record("alias", DnsRecordType::Cname, "nas", Some(45)), + record("chain", DnsRecordType::Cname, "alias", Some(30)), + record("node-alias", DnsRecordType::Cname, "node", None), + record("nas.sub", DnsRecordType::A, "10.0.0.98", None), + record("specific", DnsRecordType::Cname, "nas.sub", None), + record("missing", DnsRecordType::Cname, "absent", None), + record("cycle", DnsRecordType::Cname, "cycle.example.test.", None), + record("info", DnsRecordType::Txt, "local record", None), ], ), ( "example.test.", &[ - "cross 20 IN CNAME chain.et.net.", - "cycle IN CNAME cycle.et.net.", + record("cross", DnsRecordType::Cname, "chain.et.net.", Some(20)), + record("cycle", DnsRecordType::Cname, "cycle.et.net.", None), ], ), - ("sub.et.net.", &["nas IN A 10.0.0.99"]), + ( + "sub.et.net.", + &[record("nas", DnsRecordType::A, "10.0.0.99", None)], + ), ]); let data = data_with(Some(&dns)).await; for ip in ["10.0.0.1/24", "10.0.0.2/24"] { @@ -944,7 +959,13 @@ mod tests { #[tokio::test] async fn configured_zones_with_distinct_origins_are_served() { - let dns = config(&[("et.net.", &[]), ("example.test.", &["svc IN A 10.0.0.9"])]); + let dns = config(&[ + ("et.net.", &[]), + ( + "example.test.", + &[record("svc", DnsRecordType::A, "10.0.0.9", None)], + ), + ]); let data = data_with(Some(&dns)).await; assert_eq!( a(&data, "svc.example.test.").await, @@ -985,7 +1006,10 @@ mod tests { ); } - let dns = config(&[("et.net", &["nas IN A 10.0.0.8"])]); + let dns = config(&[( + "et.net", + &[record("nas", DnsRecordType::A, "10.0.0.8", None)], + )]); let data = data_with_default_zone("ET.NET.", Some(&dns)).await; let req = UpdateDnsRecordRequest { zone: "ET.NET".to_owned(), @@ -1010,7 +1034,10 @@ mod tests { #[tokio::test] async fn static_zones_are_loaded_during_init_before_any_rpc() { - let dns = config(&[("example.test.", &["svc IN A 10.0.0.9"])]); + let dns = config(&[( + "example.test.", + &[record("svc", DnsRecordType::A, "10.0.0.9", None)], + )]); let data = data_with(Some(&dns)).await; assert_eq!( a(&data, "svc.example.test.").await, @@ -1020,7 +1047,13 @@ mod tests { #[tokio::test] async fn system_config_keeps_default_zone_first() { - let dns = config(&[("aaa.test.", &["@ IN A 10.0.0.10"]), ("et.net.", &[])]); + let dns = config(&[ + ( + "aaa.test.", + &[record("@", DnsRecordType::A, "10.0.0.10", None)], + ), + ("et.net.", &[]), + ]); let mut data = data_with(Some(&dns)).await; let calls = Arc::new(Mutex::new(Vec::new())); data.system_config = Some(Box::new(SystemConfigSpy(calls.clone()))); @@ -1038,9 +1071,23 @@ mod tests { async fn invalid_local_configuration_is_rejected_before_startup() { for dns in [ config(&[("", &[])]), - config(&[("example.test.", &["nas IN A invalid"])]), - config(&[("example.test.", &["outside.invalid. IN A 10.0.0.8"])]), - config(&[("example.test.", &["$INCLUDE /etc/hosts"])]), + config(&[( + "example.test.", + &[record("nas", DnsRecordType::A, "invalid", None)], + )]), + config(&[( + "example.test.", + &[record( + "outside.invalid.", + DnsRecordType::A, + "10.0.0.8", + None, + )], + )]), + config(&[( + "example.test.", + &[record("nas", DnsRecordType::A, "$INCLUDE /etc/hosts", None)], + )]), ] { assert!(zone::configured_zones(Some(&dns)).is_err()); } diff --git a/easytier/src/instance/dns_server/tests.rs b/easytier/src/instance/dns_server/tests.rs index 9716e418..a8e30a5a 100644 --- a/easytier/src/instance/dns_server/tests.rs +++ b/easytier/src/instance/dns_server/tests.rs @@ -1,3 +1,4 @@ +use easytier_proto::core_config::{DnsRecordRaw, DnsRecordType, dns_record_raw::Data}; use std::net::{Ipv4Addr, SocketAddr}; use std::str::FromStr as _; use std::sync::Arc; @@ -126,7 +127,15 @@ async fn test_magic_dns_server_instance() { vec![DnsZoneConfig { origin: "example.test.".to_owned(), ttl: Some(60), - records: Some(vec!["svc IN A 10.0.0.9".to_owned()].into()), + records: Some( + vec![DnsRecordRaw { + name: "svc".into(), + r#type: DnsRecordType::A as i32, + ttl: None, + data: Some(Data::Value("10.0.0.9".into())), + }] + .into(), + ), }] .into(), ); @@ -248,7 +257,15 @@ async fn test_magic_dns_update_replaces_records_for_same_client() { vec![DnsZoneConfig { origin: "example.test.".to_owned(), ttl: Some(60), - records: Some(vec!["svc IN A 10.0.0.9".to_owned()].into()), + records: Some( + vec![DnsRecordRaw { + name: "svc".into(), + r#type: DnsRecordType::A as i32, + ttl: None, + data: Some(Data::Value("10.0.0.9".into())), + }] + .into(), + ), }] .into(), ), diff --git a/easytier/src/instance/dns_server/zone.rs b/easytier/src/instance/dns_server/zone.rs index edb5f364..b2be8fb8 100644 --- a/easytier/src/instance/dns_server/zone.rs +++ b/easytier/src/instance/dns_server/zone.rs @@ -1,22 +1,12 @@ use crate::proto::utils::RepeatedMessageModel; -use anyhow::{Context, ensure}; -use easytier_core::config::toml::{ConfigLoader, DEFAULT_ET_DNS_ZONE, DnsConfig, DnsZoneConfig}; -use hickory_proto::rr::{DNSClass, LowerName, Name, Record, RecordSet, RrKey}; -use hickory_proto::serialize::txt::Parser; +use anyhow::Context; +use easytier_core::config::dns::compile_dns; +pub(crate) use easytier_core::config::dns::parse_origin; +use easytier_core::config::toml::{ConfigLoader, DEFAULT_ET_DNS_ZONE, DnsConfig}; +use hickory_proto::rr::{LowerName, Record, RecordSet, RrKey}; use hickory_server::store::in_memory::InMemoryAuthority; use std::collections::BTreeMap; -pub(crate) fn parse_origin(origin: &str) -> anyhow::Result { - ensure!( - !origin.trim().is_empty(), - "DNS zone origin must not be empty" - ); - let mut name: Name = origin.parse().context("invalid DNS zone origin")?; - ensure!(!name.is_root(), "DNS zone origin must not be root"); - name.set_fqdn(true); - Ok(LowerName::new(&name)) -} - /// Resolves the default Magic DNS zone origin from `dns.domain`, falling back /// to the deprecated `flags.tld_dns_zone` patch when `dns.domain` is unset, and /// finally to [`DEFAULT_ET_DNS_ZONE`]. @@ -36,16 +26,10 @@ pub(crate) fn dns_domain(config: &impl ConfigLoader) -> anyhow::Result, } impl Zone { - #[cfg(test)] - pub fn origin(&self) -> &LowerName { - &self.origin - } - pub fn iter_records(&self) -> impl Iterator { self.records .values() @@ -53,74 +37,9 @@ impl Zone { } } -impl TryFrom<&DnsZoneConfig> for Zone { - type Error = anyhow::Error; - - fn try_from(value: &DnsZoneConfig) -> Result { - let origin = parse_origin(&value.origin)?; - let ttl = value.ttl.unwrap_or_default(); - let lines = value - .records - .as_ref() - .map(|list| list.items.as_slice()) - .unwrap_or_default(); - ensure!( - !lines - .iter() - .flat_map(|record| record.lines()) - .any(|line| line.trim_start().starts_with('$')), - "zone file directives are not supported in DNS records" - ); - let content = format!( - "$ORIGIN {origin}\n$TTL {ttl}\n{}\n", - // Hickory 0.25 carries an explicit record TTL into later records. - lines.join(&format!("\n$TTL {ttl}\n")) - ); - let name = Name::from(&origin); - let (_, records) = Parser::new(content, None, Some(name.clone())) - .parse() - .with_context(|| format!("invalid DNS records for zone {origin}"))?; - - for record in records.values().flat_map(RecordSet::records_without_rrsigs) { - ensure!( - name.zone_of(record.name()), - "DNS record {} is outside zone {origin}", - record.name() - ); - ensure!( - record.dns_class() == DNSClass::IN, - "DNS records must use class IN" - ); - } - - Ok(Self { origin, records }) - } -} - -impl From for DnsZoneConfig { - fn from(value: Zone) -> Self { - let records = value - .iter_records() - .map(ToString::to_string) - .collect::>(); - Self { - origin: value.origin.to_string(), - ttl: None, - records: Some(records.into()), - } - } -} - pub(crate) type ZoneGroup = RepeatedMessageModel; impl ZoneGroup { - pub fn into_groups(self) -> BTreeMap { - self.into_iter().fold(BTreeMap::new(), |mut map, zone| { - map.entry(zone.origin.clone()).or_default().push(zone); - map - }) - } - /// Merges records of every zone into one authority, since an in-memory /// authority answers authoritatively and never falls through to another. pub fn extend_authority(&self, authority: &mut InMemoryAuthority) { @@ -135,120 +54,27 @@ impl ZoneGroup { pub(crate) fn configured_zones( dns: Option<&DnsConfig>, ) -> anyhow::Result> { - let items = dns - .and_then(|dns| dns.zone.as_ref()) - .map(|zones| zones.items.as_slice()) - .unwrap_or_default(); - Ok(ZoneGroup::try_from(items)?.into_groups()) + let Some(dns) = dns else { + return Ok(BTreeMap::new()); + }; + Ok(compile_dns(dns)? + .into_iter() + .map(|(origin, zone)| { + ( + origin, + std::iter::once(Zone { + records: zone.records, + }) + .collect(), + ) + }) + .collect()) } #[cfg(test)] mod tests { use super::*; - use easytier_core::config::toml::{DnsConfig, TomlConfigLoader}; - use hickory_proto::rr::RecordType; - - fn zones(config: &DnsConfig) -> anyhow::Result { - let items = config - .zone - .as_ref() - .map(|zones| zones.items.as_slice()) - .unwrap_or_default(); - ZoneGroup::try_from(items) - } - - fn config(records: &[&str]) -> DnsConfig { - DnsConfig { - domain: None, - zone: Some( - vec![DnsZoneConfig { - origin: "EXAMPLE.test".to_owned(), - ttl: Some(60), - records: Some( - records - .iter() - .map(|record| (*record).to_owned()) - .collect::>() - .into(), - ), - }] - .into(), - ), - } - } - - #[test] - fn custom_records_parse_common_types_and_ttls() { - let config = config(&[ - "nas IN A 10.0.0.8", - "nas IN AAAA fd00::8", - "alias 120 IN CNAME nas", - "@ IN TXT \"local records\"", - ]); - let parsed = zones(&config).unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].origin().to_string(), "example.test."); - let records = parsed[0].iter_records().collect::>(); - assert_eq!(records.len(), 4); - for record in records { - assert!( - record.name().to_string().ends_with(".example.test.") - || record.name().to_string() == "example.test." - ); - assert_eq!( - record.ttl(), - if record.record_type() == RecordType::CNAME { - 120 - } else { - 60 - } - ); - } - let mut config = config; - config.zone.as_mut().unwrap().items[0].ttl = Some(0); - let parsed = zones(&config).unwrap(); - assert_eq!(parsed[0].iter_records().next().unwrap().ttl(), 0); - } - - #[test] - fn custom_records_reject_invalid_records_and_directives() { - for record in [ - "nas IN A not-an-address", - "outside.invalid. IN A 10.0.0.8", - "$INCLUDE /etc/hosts", - "nas IN A 10.0.0.8\n$ORIGIN other.test.", - ] { - assert!(zones(&config(&[record])).is_err(), "accepted {record}"); - } - for origin in ["", "."] { - let mut config = config(&[]); - config.zone.as_mut().unwrap().items[0].origin = origin.to_owned(); - assert!(zones(&config).is_err(), "accepted origin {origin}"); - } - assert!( - zones(&DnsConfig { - domain: None, - zone: None - }) - .unwrap() - .is_empty() - ); - } - - #[test] - fn zone_group_into_groups_merges_same_origin() { - let mut config = config(&["nas IN A 10.0.0.8"]); - let mut second = config.zone.as_ref().unwrap().items[0].clone(); - second.origin = "example.test.".to_owned(); - config.zone.as_mut().unwrap().items.push(second); - let groups = zones(&config).unwrap().into_groups(); - assert_eq!(groups.len(), 1); - assert_eq!( - groups[&"example.test.".parse::().unwrap()].len(), - 2 - ); - } - + use easytier_core::config::toml::TomlConfigLoader; #[test] fn dns_domain_prefers_dns_domain_and_falls_back_to_legacy_flag() { let loader = TomlConfigLoader::new_from_str("").unwrap(); @@ -267,17 +93,19 @@ mod tests { .unwrap(); assert_eq!(dns_domain(&loader).unwrap().to_string(), "mesh.example."); - let loader = TomlConfigLoader::new_from_str( - "[dns]\ndomain = ''\n[flags]\ntld_dns_zone = 'legacy.example'\n", - ) - .unwrap(); - assert!(dns_domain(&loader).is_err()); + assert!( + TomlConfigLoader::new_from_str( + "[dns]\ndomain = ''\n[flags]\ntld_dns_zone = 'legacy.example'\n", + ) + .is_err() + ); - let loader = TomlConfigLoader::new_from_str( - "[dns]\ndomain = '.'\n[flags]\ntld_dns_zone = 'legacy.example'\n", - ) - .unwrap(); - assert!(dns_domain(&loader).is_err()); + assert!( + TomlConfigLoader::new_from_str( + "[dns]\ndomain = '.'\n[flags]\ntld_dns_zone = 'legacy.example'\n", + ) + .is_err() + ); let loader = TomlConfigLoader::new_from_str("[flags]\ntld_dns_zone = '.'\n").unwrap(); assert!(dns_domain(&loader).is_err());