From 62b45608084e1dc2ecf594e18f9c5d1e68bfd28f Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 15:04:52 -0500 Subject: [PATCH 01/10] ddns: add tests and refactor longest match --- Cargo.lock | 22 +- Cargo.toml | 4 +- bin/Cargo.toml | 2 +- ddns-test/Cargo.toml | 11 - ddns-test/ddns.json | 71 ----- ddns-test/src/main.rs | 98 ------- libs/config/src/wire/v4.rs | 491 ++++++++++++++++++++++---------- libs/ddns/Cargo.toml | 3 + libs/ddns/src/lib.rs | 97 +++++++ libs/icmp-ping/Cargo.toml | 2 +- libs/ip-manager/Cargo.toml | 2 +- plugins/leases/Cargo.toml | 2 +- plugins/message-type/Cargo.toml | 2 +- plugins/static-addr/Cargo.toml | 2 +- 14 files changed, 450 insertions(+), 359 deletions(-) delete mode 100644 ddns-test/Cargo.toml delete mode 100644 ddns-test/ddns.json delete mode 100644 ddns-test/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 535a295..21bc215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,15 +807,7 @@ dependencies = [ "rand 0.8.5", "ring 0.16.20", "thiserror 1.0.59", -] - -[[package]] -name = "ddns-test" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde", - "serde_json", + "tracing-test", ] [[package]] @@ -4074,11 +4066,10 @@ dependencies = [ [[package]] name = "tracing-test" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a2c0ff408fe918a94c428a3f2ad04e4afd5c95bbc08fcf868eff750c15728a4" +checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" dependencies = [ - "lazy_static", "tracing-core", "tracing-subscriber", "tracing-test-macro", @@ -4086,13 +4077,12 @@ dependencies = [ [[package]] name = "tracing-test-macro" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258bc1c4f8e2e73a977812ab339d503e6feeb92700f6d07a6de4d321522d5c08" +checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ - "lazy_static", "quote", - "syn 1.0.109", + "syn 2.0.111", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a4c42df..aa4727b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ # main server code "dora-core", "dora-cfg", - "ddns-test", # healthcheck/diagnostics,etc "external-api", # libs @@ -39,9 +38,10 @@ tokio-util = { version = "0.7.0", features = ["codec", "net"] } tracing = "0.1.40" tracing-futures = "0.2.5" tracing-subscriber = { features = ["env-filter", "json"], version = "0.3" } +tracing-test = "0.2.5" thiserror = "1.0" rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.8" -rustls-pki-types = "1.13.1" \ No newline at end of file +rustls-pki-types = "1.13.1" diff --git a/bin/Cargo.toml b/bin/Cargo.toml index d2d7766..c84a79f 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -28,7 +28,7 @@ derive_builder = "0.12.0" crossbeam-channel = "0.5.1" rand = "0.8" socket2 = { workspace = true } -tracing-test = "0.2.4" +tracing-test = { workspace = true } [target.'cfg(not(target_env = "musl"))'.dependencies] jemallocator = { version = "0.5.0", features = ["background_threads"] } diff --git a/ddns-test/Cargo.toml b/ddns-test/Cargo.toml deleted file mode 100644 index 9edbca3..0000000 --- a/ddns-test/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "ddns-test" -version = "0.1.0" -edition = "2024" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -anyhow = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/ddns-test/ddns.json b/ddns-test/ddns.json deleted file mode 100644 index 67f73fb..0000000 --- a/ddns-test/ddns.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "DhcpDdns": { - "ip-address": "127.0.0.1", - "port": 53001, - "forward-ddns": { - "ddns-domains": [ - { - "name": "other.example.com.", - "key-name": "", - "dns-servers": [ - { - "ip-address": "8.8.8.8", - "port": 53 - } - ] - }, - { - "name": "example.com.", - "key-name": "", - "dns-servers": [ - { - "ip-address": "8.8.8.8", - "port": 53 - } - ] - }, - { - "name": "baz.other.example.com.", - "key-name": "", - "dns-servers": [ - { - "ip-address": "8.8.8.8", - "port": 53 - } - ] - } - ] - }, - "reverse-ddns": { - "ddns-domains": [ - { - "name": "168.192.in-addr.arpa.", - "key-name": "", - "dns-servers": [ - { - "ip-address": "8.8.8.8", - "port": 53 - } - ] - }, - { - "name": "8.8.8.8.in-addr.arpa.", - "key-name": "", - "dns-servers": [ - { - "ip-address": "8.8.8.8", - "port": 53 - } - ] - } - ] - }, - "tsig-keys": [ - { - "name": "tsig-key", - "algorithm": "HMAC-SHA512", - "secret": "cOMu4V6xeuEMyGuOiwMtvQNEp+4XZNqSbjdwxyNmpYwfZoy/ZSUctWsYq7XKKuQFjkiIrUON5HV+9aozYCB58A==" - } - ] - } -} diff --git a/ddns-test/src/main.rs b/ddns-test/src/main.rs deleted file mode 100644 index b6fa53d..0000000 --- a/ddns-test/src/main.rs +++ /dev/null @@ -1,98 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::net::{Ipv4Addr, UdpSocket}; - -/// @code -/// { -/// "change-type" : , -/// "forward-change" : , -/// "reverse-change" : , -/// "fqdn" : "", -/// "ip-address" : "
", -/// "dhcid" : "", -/// "lease-expires-on" : "", -/// "lease-length" : , -/// "use-conflict-resolution": -/// } -/// @endcode -/// - change-type - indicates whether this request is to add or update -/// DNS entries or to remove them. The value is an integer and is -/// 0 for add/update and 1 for remove. -/// - forward-change - indicates whether the forward (name to -/// address) DNS zone should be updated. The value is a string -/// representing a boolean. It is "true" if the zone should be updated -/// and "false" if not. (Unlike the keyword, the boolean value is -/// case-insensitive.) -/// - reverse-change - indicates whether the reverse (address to -/// name) DNS zone should be updated. The value is a string -/// representing a boolean. It is "true" if the zone should be updated -/// and "false" if not. (Unlike the keyword, the boolean value is -/// case-insensitive.) -/// - fqdn - fully qualified domain name such as "myhost.example.com.". -/// (Note that a trailing dot will be appended if not supplied.) -/// - ip-address - the IPv4 or IPv6 address of the client. The value -/// is a string representing the IP address (e.g. "192.168.0.1" or -/// "2001:db8:1::2"). -/// - dhcid - identification of the DHCP client to whom the IP address has -/// been leased. The value is a string containing an even number of -/// hexadecimal digits without delimiters such as "2C010203040A7F8E3D" -/// (case insensitive). -/// - lease-expires-on - the date and time on which the lease expires. -/// The value is a string of the form "yyyymmddHHMMSS" where: -/// - yyyy - four digit year -/// - mm - month of year (1-12), -/// - dd - day of the month (1-31), -/// - HH - hour of the day (0-23) -/// - MM - minutes of the hour (0-59) -/// - SS - seconds of the minute (0-59) -/// - lease-length - the length of the lease in seconds. This is an -/// integer and may range between 1 and 4294967295 (2^32 - 1) inclusive. -/// - use-conflict-resolution - when true, follow RFC 4703 which uses -/// DHCID records to prohibit multiple clients from updating an FQDN -/// -fn main() -> Result<()> { - let soc = UdpSocket::bind("0.0.0.0:0")?; - let update = NcrUpdate { - change_type: 0, - forward_change: true, - reverse_change: false, - fqdn: "example.com.".to_owned(), - ip_address: Ipv4Addr::from([192, 168, 2, 1]), - dhcid: "0102030405060708".to_owned(), - lease_expires_on: "20130121132405".to_owned(), - lease_length: 1300, - use_conflict_resolution: true, - }; - let s = serde_json::to_string(&update)?; - let len = s.len() as u16; - println!("sending {s} {len}"); - // expects two-byte len prepended - let mut buf = vec![]; - buf.extend(len.to_be_bytes()); - buf.extend(s.as_bytes()); - println!("{buf:#?}"); - let r = soc.send_to(&buf, "127.0.0.1:53001")?; - println!("sent size {r}"); - let mut buf = vec![0; 1024]; - let (len, from) = soc.recv_from(&mut buf)?; - // response has buf len prepended also - let buf_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; - println!("recvd len {len} from {from} buf_len {buf_len}"); - let decoded: serde_json::Value = serde_json::from_slice(&buf[2..buf_len])?; - println!("response {decoded}"); - Ok(()) -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub struct NcrUpdate { - change_type: u32, - forward_change: bool, - reverse_change: bool, - fqdn: String, - ip_address: Ipv4Addr, - dhcid: String, - lease_expires_on: String, - lease_length: u32, - use_conflict_resolution: bool, -} diff --git a/libs/config/src/wire/v4.rs b/libs/config/src/wire/v4.rs index 9a448f7..2fd793e 100644 --- a/libs/config/src/wire/v4.rs +++ b/libs/config/src/wire/v4.rs @@ -632,49 +632,50 @@ pub mod ddns { pub fn forward(&self) -> &[DdnsServer] { &self.forward } + /// get longest forward match pub fn match_longest_forward(&self, fqdn: &Name) -> Option<&DdnsServer> { match_longest_fqdn(&self.forward, fqdn) } pub fn reverse(&self) -> &[DdnsServer] { &self.reverse } + /// find longest reverse match pub fn match_longest_reverse(&self, arpa_domain: &Name) -> Option<&DdnsServer> { match_longest_fqdn(&self.reverse, arpa_domain) } } - fn match_longest_fqdn<'a>(list: &'a [DdnsServer], fqdn: &Name) -> Option<&'a DdnsServer> { + /// matches longest server entry for `fqdn` + /// server list can contain: + /// - exact matches: entry [example.com] matches on example.com + /// - zone of matches: entry [b.foo.example.com] matches on foo.example.com + /// - longest match: entry [sample.example.com, example.com] search for myhost.sample.example.com matches on sample.example.com + /// - reverse zones: [168.192.in-addr.arpa.] matches on 1.2.168.192.in-addr.arpa. + fn match_longest_fqdn<'a>( + list: &'a [ddns::DdnsServer], + fqdn: &Name, + ) -> Option<&'a ddns::DdnsServer> { + let fqdn = fqdn.to_lowercase(); + let mut best_match = None; let mut match_len = 0; for srv in list { + let config_name = srv.name.to_lowercase(); let fqdn_len = fqdn.num_labels(); - let srv_len = srv.name.num_labels(); + let srv_len = config_name.num_labels(); - if srv.name.is_wildcard() && srv_len == 0 { - return Some(srv); - } - // srv len is longer than fqdn, can't match - if srv_len > fqdn_len { - continue; - } - // if fqdn & srv have same # of labels & they are equal, then - // we found a match - if fqdn_len == srv_len { - if fqdn == &srv.name { + if config_name.is_wildcard() { + // match * + if srv_len == 0 { return Some(srv); } - continue; - } else { - let offset = fqdn_len - srv_len; - // fqdn contains the srv name - // count the # of matching labels - if fqdn - .iter() - .skip(offset as usize) - .zip(srv.name.iter()) - .all(|(a, b)| a == b) - && srv_len > match_len - { + } else if is_subdomain_of(&fqdn, &config_name) { + // if fqdn & srv have same # of labels & they are equal, then + // we found a match + if fqdn_len == srv_len { + return Some(srv); + } + if srv_len > match_len { best_match = Some(srv); match_len = srv_len; } @@ -682,167 +683,347 @@ pub mod ddns { } best_match } -} - -#[cfg(test)] -mod tests { - use super::ddns::*; - use super::*; - use ipnet::Ipv4Net; - use dora_core::dhcproto::Name; - - pub static SAMPLE_YAML: &str = include_str!("../../sample/config.yaml"); - pub static LONG_OPTS: &str = include_str!("../../sample/long_opts.yaml"); - - #[test] - fn test_untagged_opt() { - let v: Opt = - serde_json::from_str("{\"type\": \"ip\", \"value\": [\"1.2.3.4\", \"2.3.4.5\" ] }") - .unwrap(); - assert!(matches!(v, Opt::Ip(MaybeList::List(_)))); + /// test if `needle` is subdomain of `haystack` + fn is_subdomain_of(haystack: &Name, needle: &Name) -> bool { + let haystack_len = haystack.num_labels(); + let needle_len = needle.num_labels(); + if needle_len > haystack_len { + return false; + } + haystack + .iter() + .rev() + .zip(needle.iter().rev()) + .all(|(a, b)| a == b) } - #[test] - fn test_forward_match() { - let ddns = Ddns { - forward: vec![ - DdnsServer { - name: "example.com.".parse().unwrap(), + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_sample_match() { + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "sample.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "other.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "a.baz.foo.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = + ddns.match_longest_forward(&"myhost.sample.example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "sample.example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { - name: "other.example.com.".parse().unwrap(), + } + ); + // change order + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "sample.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "other.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "a.baz.foo.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = + ddns.match_longest_forward(&"myhost.sample.example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "sample.example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { - name: "foo.example.com.".parse().unwrap(), + } + ); + // change order + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "other.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "a.baz.foo.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "sample.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = + ddns.match_longest_forward(&"myhost.sample.example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "sample.example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { - name: "a.baz.foo.example.com.".parse().unwrap(), + } + ); + } + + #[test] + fn test_forward_match() { + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "other.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "foo.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "a.baz.foo.example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "bing.net.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = ddns.match_longest_forward(&"example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { - name: "bing.net.".parse().unwrap(), + } + ); + let fwd = ddns.match_longest_forward(&"other.example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "other.example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - ], - ..Ddns::default() - }; - let fwd = ddns.match_longest_forward(&"example.com.".parse::().unwrap()); - assert_eq!( - fwd.unwrap(), - &DdnsServer { - name: "example.com.".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); - let fwd = ddns.match_longest_forward(&"other.example.com.".parse::().unwrap()); - assert_eq!( - fwd.unwrap(), - &DdnsServer { - name: "other.example.com.".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); - let fwd = ddns.match_longest_forward(&"b.foo.example.com.".parse::().unwrap()); - assert_eq!( - fwd.unwrap(), - &DdnsServer { - name: "foo.example.com.".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); - let fwd = ddns.match_longest_forward(&"bang.net.".parse::().unwrap()); - assert_eq!(fwd, None); - } - - #[test] - fn test_forward_match_wildcard() { - let ddns = Ddns { - forward: vec![ - DdnsServer { - name: "example.com.".parse().unwrap(), + } + ); + let fwd = ddns.match_longest_forward(&"b.foo.example.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "foo.example.com.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { + } + ); + let fwd = ddns.match_longest_forward(&"bang.net.".parse::().unwrap()); + assert_eq!(fwd, None); + } + + #[test] + fn test_forward_match_wildcard() { + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "*".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = ddns.match_longest_forward(&"stuff.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { name: "*".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - ], - ..Ddns::default() - }; - let fwd = ddns.match_longest_forward(&"stuff.com.".parse::().unwrap()); - assert_eq!( - fwd.unwrap(), - &DdnsServer { - name: "*".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); - } + } + ); - #[test] - fn test_reverse() { - let ddns = Ddns { - reverse: vec![ - DdnsServer { - name: "8.8.8.8.in-addr.arpa.".parse().unwrap(), + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "*.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = ddns.match_longest_forward(&"stuff.com.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "*.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { + } + ); + } + + #[test] + fn test_forward_match_wildcard_zone() { + let ddns = Ddns { + forward: vec![ + DdnsServer { + name: "example.com.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "org.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let fwd = ddns.match_longest_forward(&"wiki.org.".parse::().unwrap()); + assert_eq!( + fwd.unwrap(), + &DdnsServer { + name: "org.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + } + ); + } + + #[test] + fn test_reverse() { + let ddns = Ddns { + reverse: vec![ + DdnsServer { + name: "8.8.8.8.in-addr.arpa.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "168.192.in-addr.arpa.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + DdnsServer { + name: "1.192.in-addr.arpa.".parse().unwrap(), + key: None, + ip: ([8, 8, 8, 8], 53).into(), + }, + ], + ..Ddns::default() + }; + let rev = + ddns.match_longest_reverse(&"1.2.168.192.in-addr.arpa.".parse::().unwrap()); + assert_eq!( + rev.unwrap(), + &DdnsServer { name: "168.192.in-addr.arpa.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - DdnsServer { + } + ); + + let rev = ddns.match_longest_reverse(&"4.4.8.8.in-addr.arpa.".parse::().unwrap()); + assert_eq!(rev, None); + + let rev = ddns.match_longest_reverse(&"10.192.in-addr.arpa.".parse::().unwrap()); + assert_eq!(rev, None); + + let rev = ddns.match_longest_reverse(&"3.1.192.in-addr.arpa.".parse::().unwrap()); + assert_eq!( + rev.unwrap(), + &DdnsServer { name: "1.192.in-addr.arpa.".parse().unwrap(), key: None, ip: ([8, 8, 8, 8], 53).into(), - }, - ], - ..Ddns::default() - }; - let rev = ddns.match_longest_reverse(&"1.2.168.192.in-addr.arpa.".parse::().unwrap()); - assert_eq!( - rev.unwrap(), - &DdnsServer { - name: "168.192.in-addr.arpa.".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); + } + ); + } + } +} - let rev = ddns.match_longest_reverse(&"4.4.8.8.in-addr.arpa.".parse::().unwrap()); - assert_eq!(rev, None); +#[cfg(test)] +mod tests { + use super::*; + use ipnet::Ipv4Net; - let rev = ddns.match_longest_reverse(&"10.192.in-addr.arpa.".parse::().unwrap()); - assert_eq!(rev, None); + pub static SAMPLE_YAML: &str = include_str!("../../sample/config.yaml"); + pub static LONG_OPTS: &str = include_str!("../../sample/long_opts.yaml"); - let rev = ddns.match_longest_reverse(&"3.1.192.in-addr.arpa.".parse::().unwrap()); - assert_eq!( - rev.unwrap(), - &DdnsServer { - name: "1.192.in-addr.arpa.".parse().unwrap(), - key: None, - ip: ([8, 8, 8, 8], 53).into(), - } - ); + #[test] + fn test_untagged_opt() { + let v: Opt = + serde_json::from_str("{\"type\": \"ip\", \"value\": [\"1.2.3.4\", \"2.3.4.5\" ] }") + .unwrap(); + assert!(matches!(v, Opt::Ip(MaybeList::List(_)))); } - // test we can encode/decode sample #[test] fn test_sample() { diff --git a/libs/ddns/Cargo.toml b/libs/ddns/Cargo.toml index dd5240c..75af278 100644 --- a/libs/ddns/Cargo.toml +++ b/libs/ddns/Cargo.toml @@ -14,6 +14,9 @@ ring = "0.16.20" hex = "0.4" rand = { workspace = true } +[dev-dependencies] +tracing-test = { workspace = true } + [[example]] name = "tsig" diff --git a/libs/ddns/src/lib.rs b/libs/ddns/src/lib.rs index 806517a..45a50f1 100644 --- a/libs/ddns/src/lib.rs +++ b/libs/ddns/src/lib.rs @@ -324,6 +324,14 @@ fn handle_flags( #[cfg(test)] mod tests { + use std::{collections::HashMap, net::SocketAddr}; + + use config::wire::v4::ddns::{self, DdnsServer}; + use dora_core::{ + hickory_proto::{self, op}, + tokio::{self, net::UdpSocket}, + }; + use super::*; fn harness( @@ -434,4 +442,93 @@ mod tests { true, ); } + + async fn mock_dns_server() -> (SocketAddr, tokio::task::JoinHandle<()>) { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = socket.local_addr().unwrap(); + + let handle = tokio::spawn(async move { + loop { + let mut buf = [0; 512]; + if let Ok((len, src)) = socket.recv_from(&mut buf).await { + let request = op::Message::from_vec(&buf[..len]).unwrap(); + assert_eq!(request.op_code(), hickory_proto::op::OpCode::Update); + + let mut response = op::Message::new(); + response + .set_id(request.id()) + .set_op_code(op::OpCode::Update) + .set_message_type(op::MessageType::Response) + .set_response_code(op::ResponseCode::NoError); + + let response_bytes = response.to_vec().unwrap(); + socket.send_to(&response_bytes, src).await.unwrap(); + } + } + }); + + (addr, handle) + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn test_ddns_send() { + let (server_addr, server_handle) = mock_dns_server().await; + + let key_name = "test.key."; + // This is a 32-byte secret key. + let key_bytes = b"secretkeythatislongenoughfor256"; + // The configuration requires the key to be Base64 encoded. + let key_data = BASE64_STANDARD.encode(key_bytes); + + let mut tsig_keys = HashMap::new(); + tsig_keys.insert( + key_name.to_string(), + ddns::TsigKey { + algorithm: ddns::Algorithm::HmacSha256.into(), + data: key_data, + }, + ); + + let fwd_zone = Name::from_str("example.com.").unwrap(); + let rev_zone = Name::from_str("1.168.192.in-addr.arpa.").unwrap(); + + let ddns_config = Ddns { + enable_updates: true, + forward: vec![DdnsServer { + name: fwd_zone.clone(), + key: Some(key_name.to_string()), + ip: server_addr, + }], + reverse: vec![DdnsServer { + name: rev_zone.clone(), + key: Some(key_name.to_string()), + ip: server_addr, + }], + tsig_keys, + ..Default::default() + }; + + let updater = DdnsUpdate::new(); + let duid = DhcId::duid(b"test-duid"); + let leased_ip = Ipv4Addr::new(192, 168, 1, 10); + let lease_length = 3600; + let domain = Name::from_str("myhost.example.com.").unwrap(); + + let result = updater + .send_dns( + &ddns_config, + duid, + leased_ip, + lease_length, + domain, + true, + true, + ) + .await; + + assert!(result.is_ok()); + + server_handle.abort(); + } } diff --git a/libs/icmp-ping/Cargo.toml b/libs/icmp-ping/Cargo.toml index 50bb642..a12327f 100644 --- a/libs/icmp-ping/Cargo.toml +++ b/libs/icmp-ping/Cargo.toml @@ -19,4 +19,4 @@ dora-core = { path = "../../dora-core" } [dev-dependencies] tokio = { workspace = true } tracing-subscriber = "0.3" -tracing-test = "0.2.4" +tracing-test = { workspace = true } diff --git a/libs/ip-manager/Cargo.toml b/libs/ip-manager/Cargo.toml index a1bc7af..1220a22 100644 --- a/libs/ip-manager/Cargo.toml +++ b/libs/ip-manager/Cargo.toml @@ -31,5 +31,5 @@ sqlx = { version = "0.5.13", features = [ tokio-test = "0.4.1" tracing = { workspace = true, features = ["log"] } tokio = { workspace = true } -tracing-test = "0.2.4" +tracing-test = { workspace = true } rand = { workspace = true } diff --git a/plugins/leases/Cargo.toml b/plugins/leases/Cargo.toml index 3e72346..7ba05b6 100644 --- a/plugins/leases/Cargo.toml +++ b/plugins/leases/Cargo.toml @@ -22,4 +22,4 @@ ipnet = { workspace = true } [dev-dependencies] serde_yaml = { workspace = true } -tracing-test = "0.2.4" +tracing-test = { workspace = true } diff --git a/plugins/message-type/Cargo.toml b/plugins/message-type/Cargo.toml index 10fa2c1..6801f7b 100644 --- a/plugins/message-type/Cargo.toml +++ b/plugins/message-type/Cargo.toml @@ -14,4 +14,4 @@ client-protection = { path = "../../libs/client-protection" } [dev-dependencies] serde_yaml = { workspace = true } -tracing-test = "0.2.4" +tracing-test = { workspace = true } diff --git a/plugins/static-addr/Cargo.toml b/plugins/static-addr/Cargo.toml index 3a84c5b..aaee689 100644 --- a/plugins/static-addr/Cargo.toml +++ b/plugins/static-addr/Cargo.toml @@ -15,5 +15,5 @@ message-type = { path = "../message-type" } [dev-dependencies] serde_yaml = { workspace = true } -tracing-test = "0.2.4" +tracing-test = { workspace = true } hex = "0.4" From f58b8101594fc72fd1a4f0530c8817c2b41cc6c2 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 15:16:07 -0500 Subject: [PATCH 02/10] ping: fix multiping test --- libs/icmp-ping/src/lib.rs | 52 +++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/libs/icmp-ping/src/lib.rs b/libs/icmp-ping/src/lib.rs index 1922f18..55990e8 100644 --- a/libs/icmp-ping/src/lib.rs +++ b/libs/icmp-ping/src/lib.rs @@ -462,38 +462,42 @@ mod tests { Ok(()) } + // probably shouldn't use an external server #[tokio::test] #[traced_test] async fn test_multiping() -> errors::Result<()> { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); let listener = Listener::::new()?; - let pinger = listener.pinger("1.1.1.1".parse().unwrap()); - let a = tokio::spawn(async move { - for i in 1..5 { - let res = pinger.ping(i).await?; - assert_eq!(res.reply.seq_cnt, i); - } - assert!(pinger.map.lock().is_empty()); - Ok::<_, errors::Error>(()) - }); - let pinger = listener.pinger("8.8.8.8".parse().unwrap()); - let b = tokio::spawn(async move { - for i in 1..5 { - let res = pinger.ping(i).await?; - assert_eq!(res.reply.seq_cnt, i); + + let a = tokio::spawn({ + let barrier = barrier.clone(); + let pinger = listener.pinger("1.1.1.1".parse().unwrap()); + async move { + for i in 1..5 { + let res = pinger.ping(i).await?; + assert_eq!(res.reply.seq_cnt, i); + } + // wait for both to finish + barrier.wait().await; + assert!(pinger.map.lock().is_empty()); + Ok::<_, errors::Error>(()) } - assert!(pinger.map.lock().is_empty()); - Ok::<_, errors::Error>(()) }); - let pinger = listener.pinger("1.0.0.1".parse().unwrap()); - let c = tokio::spawn(async move { - for i in 10..15 { - let res = pinger.ping(i).await?; - assert_eq!(res.reply.seq_cnt, i); + let c = tokio::spawn({ + let barrier = barrier.clone(); + let pinger = listener.pinger("1.0.0.1".parse().unwrap()); + async move { + for i in 10..15 { + let res = pinger.ping(i).await?; + assert_eq!(res.reply.seq_cnt, i); + } + // wait for both to finish + barrier.wait().await; + assert!(pinger.map.lock().is_empty()); + Ok::<_, errors::Error>(()) } - assert!(pinger.map.lock().is_empty()); - Ok::<_, errors::Error>(()) }); - let _ = tokio::try_join!(a, b, c).unwrap(); + let _ = tokio::try_join!(a, c).unwrap(); Ok(()) } From e7be3419d51ecb2ef46b19c3c47eb0999397935d Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:27:07 -0500 Subject: [PATCH 03/10] tests: fix cargo test runner --- .cargo/config.toml | 3 --- Cargo.toml | 1 + bin/tests/common/env.rs | 31 ++++++++++++++++++++----------- docs/docker.md | 2 +- util/entrypoint.sh | 9 ++++----- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index eb24714..ea99032 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,9 +4,6 @@ git-fetch-with-cli = true [target.armv7-unknown-linux-gnueabihf] linker = "arm-linux-gnueabihf-gcc" -[target.x86_64-unknown-linux-gnu] -runner = "sudo -E" - # Maybe there is a way to only run the integration tests, or remove this restriction... [env] RUST_TEST_THREADS = "1" diff --git a/Cargo.toml b/Cargo.toml index aa4727b..d468c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ resolver = "2" [workspace.dependencies] hickory-proto = { version = "0.25.2", default-features = false, features = [ "dnssec-ring", + "tokio", "serde", ] } socket2 = { version = "0.5.6", features = [ diff --git a/bin/tests/common/env.rs b/bin/tests/common/env.rs index 6bf44dc..5aed07f 100644 --- a/bin/tests/common/env.rs +++ b/bin/tests/common/env.rs @@ -1,6 +1,6 @@ use std::{ env, fs, - process::{Child, Command}, + process::{Child, Command, Stdio}, thread, }; @@ -54,30 +54,36 @@ impl Drop for DhcpServerEnv { } } +const SUDO: &str = "sudo"; + fn create_test_net_namespace(netns: &str) { - run_cmd(&format!("ip netns add {netns}")); + run_cmd(&format!("{SUDO} ip netns add {netns}")); } fn remove_test_net_namespace(netns: &str) { - run_cmd_ignore_failure(&format!("ip netns del {netns}")); + run_cmd_ignore_failure(&format!("{SUDO} ip netns del {netns}")); } fn create_test_veth_nics(netns: &str, srv_ip: &str, veth_cli: &str, veth_srv: &str) { run_cmd(&format!( - "ip link add {veth_cli} type veth peer name {veth_srv}", + "{SUDO} ip link add {veth_cli} type veth peer name {veth_srv}", + )); + run_cmd(&format!("{SUDO} ip link set {veth_cli} up")); + run_cmd(&format!("{SUDO} ip link set {veth_srv} netns {netns}",)); + run_cmd(&format!( + "{SUDO} ip netns exec {netns} ip link set {veth_srv} up", )); - run_cmd(&format!("ip link set {veth_cli} up")); - run_cmd(&format!("ip link set {veth_srv} netns {netns}",)); - run_cmd(&format!("ip netns exec {netns} ip link set {veth_srv} up",)); run_cmd(&format!( - "ip netns exec {netns} ip addr add {srv_ip}/24 dev {veth_srv}", + "{SUDO} ip netns exec {netns} ip addr add {srv_ip}/24 dev {veth_srv}", )); // TODO: remove this eventually - run_cmd(&format!("ip addr add 192.168.2.99/24 dev {veth_cli}")); + run_cmd(&format!( + "{SUDO} ip addr add 192.168.2.99/24 dev {veth_cli}" + )); } fn remove_test_veth_nics(veth_cli: &str) { - run_cmd_ignore_failure(&format!("ip link del {veth_cli}")); + run_cmd_ignore_failure(&format!("{SUDO} ip link del {veth_cli}")); } fn start_dhcp_server(config: &str, netns: &str, db: &str) -> Child { @@ -87,13 +93,16 @@ fn start_dhcp_server(config: &str, netns: &str, db: &str) -> Child { let dora_debug = format!( "{bin_path} -d={db} --config-path={config_path} --threads=2 --dora-log=debug --v4-addr=0.0.0.0:9900", ); - let cmd = format!("ip netns exec {netns} {dora_debug}"); + let cmd = format!("{SUDO} ip netns exec {netns} {dora_debug}"); let cmds: Vec<&str> = cmd.split(' ').collect(); let mut child = Command::new(cmds[0]) .args(&cmds[1..]) + // seems to mess up output formatting + .stdin(Stdio::null()) .spawn() .expect("Failed to start DHCP server"); + thread::sleep(std::time::Duration::from_secs(1)); if let Ok(Some(ret)) = child.try_wait() { panic!("Failed to start DHCP server {ret:?}"); diff --git a/docs/docker.md b/docs/docker.md index b110de3..76bdc4e 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,6 +1,6 @@ # Creating a dora docker image -After checking out the ource, build dora in docker and create an image: +After checking out the source, build dora in docker and create an image: ``` docker build -t dora . diff --git a/util/entrypoint.sh b/util/entrypoint.sh index e8addec..ef96492 100755 --- a/util/entrypoint.sh +++ b/util/entrypoint.sh @@ -33,7 +33,7 @@ if [ -z "$1" ]; then fi if [ -n "$IFACE" ]; then - # Run dhcpd for specified interface or all interfaces + # Run dora for specified interface or all interfaces data_dir="/var/lib/dora" if [ ! -d "$data_dir" ]; then @@ -42,10 +42,9 @@ if [ -n "$IFACE" ]; then exit 1 fi - dhcpd_conf="$data_dir/config.yaml" - if [ ! -r "$dhcpd_conf" ]; then - echo "Please ensure '$dhcpd_conf' exists and is readable." - echo "Run the container with arguments 'man dhcpd.conf' if you need help with creating the configuration." + dora_conf="$data_dir/config.yaml" + if [ ! -r "$dora_conf" ]; then + echo "Please ensure '$dora_conf' exists and is readable." exit 1 fi From fa6276fbb0494c17baf6e8920d00528c5825e47d Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:39:52 -0500 Subject: [PATCH 04/10] github: use sudo -E runner for github --- .github/workflows/actions.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index db20af6..a6b609a 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -49,10 +49,7 @@ jobs: override: true - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --all-features --exclude register_derive_impl --workspace + run: sudo -E cargo test --all-features --exclude register_derive_impl --workspace env: SQLX_OFFLINE: true From 41480d9e71f0d482f3775d993d8ae1ff5ad40499 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:42:43 -0500 Subject: [PATCH 05/10] try something else --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index a6b609a..cd323e7 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -49,7 +49,7 @@ jobs: override: true - name: Run cargo test - run: sudo -E cargo test --all-features --exclude register_derive_impl --workspace + run: sudo sh -c "cargo test --all-features --exclude register_derive_impl --workspace" env: SQLX_OFFLINE: true From 319270fd0ba3c74ade7f354706cbc57e12d48343 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:45:28 -0500 Subject: [PATCH 06/10] one last try --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index cd323e7..f65a0dc 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -49,7 +49,7 @@ jobs: override: true - name: Run cargo test - run: sudo sh -c "cargo test --all-features --exclude register_derive_impl --workspace" + run: sudo $(which cargo) test --all-features --exclude register_derive_impl --workspace env: SQLX_OFFLINE: true From d488cf650b4b1e995b5775118f18e34e96e82201 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:52:21 -0500 Subject: [PATCH 07/10] add net_raw capability --- .github/workflows/actions.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index f65a0dc..e288488 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -34,6 +34,9 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest + container: + image: ubuntu-latest + options: --cap-add NET_RAW # Add the capability here strategy: matrix: rust: @@ -48,8 +51,14 @@ jobs: toolchain: ${{ matrix.rust }} override: true + - name: Allow ICMP socket creation + run: sudo sysctl -w net.ipv4.ping_group_range="0 2147483647" + - name: Run cargo test - run: sudo $(which cargo) test --all-features --exclude register_derive_impl --workspace + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --exclude register_derive_impl --workspace env: SQLX_OFFLINE: true From e50e32fd8f57f8c35e8c23095d160a84041fb310 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 16:56:00 -0500 Subject: [PATCH 08/10] add net_raw capability --- .github/workflows/actions.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index e288488..cd71324 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -35,8 +35,8 @@ jobs: name: Test Suite runs-on: ubuntu-latest container: - image: ubuntu-latest - options: --cap-add NET_RAW # Add the capability here + image: ubuntu:latest + options: --cap-add NET_RAW strategy: matrix: rust: From 89789fdfdee303d4557eeeb533e9feec1b56477e Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 17:00:55 -0500 Subject: [PATCH 09/10] try sysctl --- .github/workflows/actions.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index cd71324..e5c7988 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -34,9 +34,9 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest - container: - image: ubuntu:latest - options: --cap-add NET_RAW + # container: + # image: ubuntu:latest + # options: --cap-add=NET_RAW strategy: matrix: rust: From d3a1549b0555e7c64af0b92fe446cec026f69942 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Tue, 30 Dec 2025 17:06:05 -0500 Subject: [PATCH 10/10] add socket creation to coverage --- .github/workflows/actions.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index e5c7988..1e7fe9d 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -139,6 +139,10 @@ jobs: components: llvm-tools-preview - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov + + - name: Allow ICMP socket creation + run: sudo sysctl -w net.ipv4.ping_group_range="0 2147483647" + - name: Generate code coverage id: coverage run: cargo llvm-cov --all-features --exclude register_derive_impl --workspace --no-fail-fast --lcov --output-path lcov.info