Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 81 additions & 31 deletions src/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ cfg_if::cfg_if! {
use std::ptr::{null_mut};
use core::ffi::{c_void};

pub(crate) fn dns_query_srv_records(name: &str) -> Vec<String> {
/// Queries SRV records for `name`, returning each `(target, port)`.
///
/// `DnsQuery_W` yields a linked list that may include non-SRV records (e.g. A records for
/// the SRV targets), so we walk `pNext` and keep only SRV entries.
pub(crate) fn dns_query_srv_records(name: &str) -> Vec<(String, u16)> {
let mut records = Vec::new();

let mut p_query_results: *mut DNS_RECORDA = null_mut();
Expand All @@ -30,23 +34,29 @@ cfg_if::cfg_if! {

match dns_status.ok() {
Ok(()) => {
// SAFETY: `p_query_results` in non-null because `dns_status` is Ok.
let query_results = unsafe { *p_query_results };
// SAFETY: `query_results.Data` is guaranteed to contain `SRV` because
// of arguments to `DnsQuery_W`.
let p_name_target = unsafe { query_results.Data.Srv.pNameTarget };
let name_target = PWSTR::from_raw(p_name_target.as_ptr() as *mut u16);
// SAFETY: `name_target` is guaranteed to be correct at this point.
let name_target = unsafe {name_target.to_string()};

if let Ok(name_target) = name_target {
records.push(name_target);
let mut p_record = p_query_results;
while !p_record.is_null() {
// SAFETY: `p_record` is non-null and points to a valid `DNS_RECORDA`.
let record = unsafe { *p_record };

if record.wType == DNS_TYPE_SRV.0 {
// SAFETY: `wType == DNS_TYPE_SRV` guarantees the `Srv` union member is active.
let srv = unsafe { record.Data.Srv };
// `DnsQuery_W` returns wide strings even in the `*A` record struct.
let name_target = PWSTR::from_raw(srv.pNameTarget.as_ptr() as *mut u16);
// SAFETY: `name_target` points to a valid, null-terminated UTF-16 string.
if let Ok(name_target) = unsafe { name_target.to_string() } {
records.push((name_target, srv.wPort));
}
}

p_record = record.pNext;
}
}
Err(error) => error!(%error, "DnsQuery_W failed"),
}

// SAFETY: `p_query_results` is not null.
// SAFETY: `DnsFree` tolerates a null pointer.
unsafe {
DnsFree(Some(p_query_results as *const c_void), DnsFreeRecordList);
}
Expand Down Expand Up @@ -136,14 +146,14 @@ cfg_if::cfg_if! {
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);

if !krb_tcp_srv.is_empty() {
return krb_tcp_srv.iter().map(|x| format!("tcp://{x}:88")).collect()
return srv_records_to_kdc_urls("tcp", &krb_tcp_srv)
}

let krb_udp_name = &format!("_kerberos._udp.{domain}");
let krb_udp_srv = dns_query_srv_records(krb_udp_name);

if !krb_udp_srv.is_empty() {
return krb_udp_srv.iter().map(|x| format!("udp://{x}:88")).collect()
return srv_records_to_kdc_urls("udp", &krb_udp_srv)
}

Vec::new()
Expand Down Expand Up @@ -239,14 +249,16 @@ cfg_if::cfg_if! {
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);

if !krb_tcp_srv.is_empty() {
return krb_tcp_srv.iter().map(|x| format!("tcp://{}:{}", &x.target, x.port)).collect()
let records: Vec<(String, u16)> = krb_tcp_srv.into_iter().map(|x| (x.target, x.port)).collect();
return srv_records_to_kdc_urls("tcp", &records)
}

let krb_udp_name = &format!("_kerberos._udp.{domain}");
let krb_udp_srv = dns_query_srv_records(krb_udp_name);

if !krb_udp_srv.is_empty() {
return krb_udp_srv.iter().map(|x| format!("udp://{}:{}", &x.target, x.port)).collect()
let records: Vec<(String, u16)> = krb_udp_srv.into_iter().map(|x| (x.target, x.port)).collect();
return srv_records_to_kdc_urls("udp", &records)
}

Vec::new()
Expand Down Expand Up @@ -341,23 +353,15 @@ cfg_if::cfg_if! {

if let Some(resolver) = get_dns_resolver(domain) {
if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._tcp.{domain}"))) {
for record in records {
let port = record.port();
let target_name = record.target().to_string();
let target_name = target_name.trim_end_matches('.').to_string();
let kdc_host = format!("tcp://{}:{}", &target_name, port);
kdc_hosts.push(kdc_host);
}
let records: Vec<(String, u16)> =
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
kdc_hosts.extend(srv_records_to_kdc_urls("tcp", &records));
}

if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._udp.{domain}"))) {
for record in records {
let port = record.port();
let target_name = record.target().to_string();
let target_name = target_name.trim_end_matches('.').to_string();
let kdc_host = format!("udp://{}:{}", &target_name, port);
kdc_hosts.push(kdc_host);
}
let records: Vec<(String, u16)> =
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
kdc_hosts.extend(srv_records_to_kdc_urls("udp", &records));
}
}

Expand Down Expand Up @@ -397,6 +401,21 @@ where
}
}

/// Builds `scheme://host:port` KDC URLs from resolved SRV `(target, port)` records.
///
/// DNS returns absolute names with a trailing dot (`dc.example.com.`); it is stripped so the
/// result parses as a URL host.
///
/// Limitation: SRV priority/weight (RFC 2782) are not applied here — candidates keep the order the
/// platform resolver returned (Windows `DnsQuery_W` already sorts by priority/weight; hickory and
/// `async_dnssd` do not). Proper priority ordering + weighted selection is a follow-up.
fn srv_records_to_kdc_urls(scheme: &str, records: &[(String, u16)]) -> Vec<String> {
records
.iter()
.map(|(target, port)| format!("{scheme}://{}:{port}", target.trim_end_matches('.')))
.collect()
Comment on lines +412 to +416

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented as a known limitation, not for this PR.

}

#[allow(unused_variables)]
#[instrument(level = "debug", ret)]
pub(crate) fn detect_kdc_hosts_from_dns(domain: &str) -> Vec<String> {
Expand All @@ -412,3 +431,34 @@ pub(crate) fn detect_kdc_hosts_from_dns(domain: &str) -> Vec<String> {
}
}
}

#[cfg(test)]
mod tests {
use super::srv_records_to_kdc_urls;

#[test]
fn builds_scheme_host_port_and_preserves_order() {
let records = vec![("dc1.example.com".to_owned(), 88), ("dc2.example.com".to_owned(), 8888)];
assert_eq!(
srv_records_to_kdc_urls("tcp", &records),
vec![
"tcp://dc1.example.com:88".to_owned(),
"tcp://dc2.example.com:8888".to_owned()
]
);
}

#[test]
fn strips_trailing_dot_from_absolute_names() {
let records = vec![("dc.example.com.".to_owned(), 88)];
assert_eq!(
srv_records_to_kdc_urls("udp", &records),
vec!["udp://dc.example.com:88".to_owned()]
);
}

#[test]
fn empty_input_yields_empty() {
assert!(srv_records_to_kdc_urls("tcp", &[]).is_empty());
}
}
67 changes: 62 additions & 5 deletions src/kdc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,29 @@ pub(crate) fn detect_kdc_hosts_from_system(domain: &str) -> Vec<String> {
for krb5_conf_path in krb5_conf_paths {
if krb5_conf_path.exists()
&& let Some(krb5_conf) = Krb5Conf::new_from_file(krb5_conf_path)
&& let Some(kdc) = krb5_conf.get_value(vec!["realms", domain, "kdc"])
{
let kdc_url = format!("tcp://{}", kdc.as_str());
return vec![kdc_url];
let kdcs = krb5_conf.get_all_values(vec!["realms", domain, "kdc"]);
if !kdcs.is_empty() {
return krb5_kdc_values_to_hosts(&kdcs);
}
}
}

Vec::new()
}

/// Maps krb5.conf `kdc = ` values to an ordered list of `tcp://` host URLs.
///
/// Per the MIT krb5 spec each `kdc = ` line names exactly one host (optionally `host:port`);
/// multiple KDCs for a realm are expressed as multiple `kdc = ` lines, which [`get_all_values`]
/// returns in order. See <https://web.mit.edu/kerberos/krb5-current/doc/admin/conf_files/krb5_conf.html>.
///
/// [`get_all_values`]: crate::krb::Krb5Conf::get_all_values
#[cfg(not(target_os = "windows"))]
fn krb5_kdc_values_to_hosts(kdcs: &[String]) -> Vec<String> {
kdcs.iter().map(|host| format!("tcp://{host}")).collect()
}

#[instrument(ret, level = "debug")]
pub(crate) fn detect_kdc_hosts(domain: &str) -> Vec<String> {
if let Ok(kdc_url) = env::var(format!("SSPI_KDC_URL_{domain}")) {
Expand Down Expand Up @@ -80,8 +93,19 @@ pub fn detect_kdc_host(domain: &str) -> Option<String> {
}

pub fn detect_kdc_url(domain: &str) -> Option<Url> {
let kdc_host = detect_kdc_host(domain)?;
Url::from_str(&kdc_host).ok()
detect_kdc_urls(domain).into_iter().next()
}

/// Resolves the ordered list of candidate KDC URLs for `domain`.
///
/// Same resolution order as [`detect_kdc_hosts`] (env → system store → DNS SRV), but returns every
/// candidate rather than just the first, so callers can fail over to the next KDC when one is
/// unreachable.
pub fn detect_kdc_urls(domain: &str) -> Vec<Url> {
detect_kdc_hosts(domain)
.iter()
.filter_map(|host| Url::from_str(host).ok())
.collect()
}

#[cfg(test)]
Expand All @@ -99,4 +123,37 @@ mod tests {
}
}
}

#[cfg(not(target_os = "windows"))]
#[test]
fn krb5_kdc_values_expand_to_one_host_each() {
use super::krb5_kdc_values_to_hosts;

// Single host.
assert_eq!(
krb5_kdc_values_to_hosts(&["dc1.example.com".to_owned()]),
vec!["tcp://dc1.example.com".to_owned()]
);

// Multiple `kdc = ` lines preserve order.
assert_eq!(
krb5_kdc_values_to_hosts(&["dc1.example.com".to_owned(), "dc2.example.com".to_owned()]),
vec!["tcp://dc1.example.com".to_owned(), "tcp://dc2.example.com".to_owned()]
);

// One host per line (MIT spec): a whitespace-separated line is not split.
assert_eq!(
krb5_kdc_values_to_hosts(&["dc1.example.com dc2.example.com".to_owned()]),
vec!["tcp://dc1.example.com dc2.example.com".to_owned()]
);

// An explicit port is preserved.
assert_eq!(
krb5_kdc_values_to_hosts(&["dc1.example.com:8888".to_owned()]),
vec!["tcp://dc1.example.com:8888".to_owned()]
);

// Nothing in, nothing out.
assert!(krb5_kdc_values_to_hosts(&[]).is_empty());
}
}
Loading