Skip to content

Commit 7052bb1

Browse files
committed
feat(kerberos): honor DNS SRV port and fail over across multiple KDCs
1 parent 7973654 commit 7052bb1

6 files changed

Lines changed: 343 additions & 56 deletions

File tree

‎src/dns.rs‎

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ cfg_if::cfg_if! {
1111
use std::ptr::{null_mut};
1212
use core::ffi::{c_void};
1313

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

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

3135
match dns_status.ok() {
3236
Ok(()) => {
33-
// SAFETY: `p_query_results` in non-null because `dns_status` is Ok.
34-
let query_results = unsafe { *p_query_results };
35-
// SAFETY: `query_results.Data` is guaranteed to contain `SRV` because
36-
// of arguments to `DnsQuery_W`.
37-
let p_name_target = unsafe { query_results.Data.Srv.pNameTarget };
38-
let name_target = PWSTR::from_raw(p_name_target.as_ptr() as *mut u16);
39-
// SAFETY: `name_target` is guaranteed to be correct at this point.
40-
let name_target = unsafe {name_target.to_string()};
41-
42-
if let Ok(name_target) = name_target {
43-
records.push(name_target);
37+
let mut p_record = p_query_results;
38+
while !p_record.is_null() {
39+
// SAFETY: `p_record` is non-null and points to a valid `DNS_RECORDA`.
40+
let record = unsafe { *p_record };
41+
42+
if record.wType == DNS_TYPE_SRV.0 {
43+
// SAFETY: `wType == DNS_TYPE_SRV` guarantees the `Srv` union member is active.
44+
let srv = unsafe { record.Data.Srv };
45+
// `DnsQuery_W` returns wide strings even in the `*A` record struct.
46+
let name_target = PWSTR::from_raw(srv.pNameTarget.as_ptr() as *mut u16);
47+
// SAFETY: `name_target` points to a valid, null-terminated UTF-16 string.
48+
if let Ok(name_target) = unsafe { name_target.to_string() } {
49+
records.push((name_target, srv.wPort));
50+
}
51+
}
52+
53+
p_record = record.pNext;
4454
}
4555
}
4656
Err(error) => error!(%error, "DnsQuery_W failed"),
4757
}
4858

49-
// SAFETY: `p_query_results` is not null.
59+
// SAFETY: `DnsFree` tolerates a null pointer.
5060
unsafe {
5161
DnsFree(Some(p_query_results as *const c_void), DnsFreeRecordList);
5262
}
@@ -136,14 +146,14 @@ cfg_if::cfg_if! {
136146
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);
137147

138148
if !krb_tcp_srv.is_empty() {
139-
return krb_tcp_srv.iter().map(|x| format!("tcp://{x}:88")).collect()
149+
return srv_records_to_kdc_urls("tcp", &krb_tcp_srv)
140150
}
141151

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

145155
if !krb_udp_srv.is_empty() {
146-
return krb_udp_srv.iter().map(|x| format!("udp://{x}:88")).collect()
156+
return srv_records_to_kdc_urls("udp", &krb_udp_srv)
147157
}
148158

149159
Vec::new()
@@ -239,14 +249,16 @@ cfg_if::cfg_if! {
239249
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);
240250

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

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

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

252264
Vec::new()
@@ -341,23 +353,15 @@ cfg_if::cfg_if! {
341353

342354
if let Some(resolver) = get_dns_resolver(domain) {
343355
if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._tcp.{domain}"))) {
344-
for record in records {
345-
let port = record.port();
346-
let target_name = record.target().to_string();
347-
let target_name = target_name.trim_end_matches('.').to_string();
348-
let kdc_host = format!("tcp://{}:{}", &target_name, port);
349-
kdc_hosts.push(kdc_host);
350-
}
356+
let records: Vec<(String, u16)> =
357+
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
358+
kdc_hosts.extend(srv_records_to_kdc_urls("tcp", &records));
351359
}
352360

353361
if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._udp.{domain}"))) {
354-
for record in records {
355-
let port = record.port();
356-
let target_name = record.target().to_string();
357-
let target_name = target_name.trim_end_matches('.').to_string();
358-
let kdc_host = format!("udp://{}:{}", &target_name, port);
359-
kdc_hosts.push(kdc_host);
360-
}
362+
let records: Vec<(String, u16)> =
363+
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
364+
kdc_hosts.extend(srv_records_to_kdc_urls("udp", &records));
361365
}
362366
}
363367

@@ -397,6 +401,21 @@ where
397401
}
398402
}
399403

404+
/// Builds `scheme://host:port` KDC URLs from resolved SRV `(target, port)` records.
405+
///
406+
/// DNS returns absolute names with a trailing dot (`dc.example.com.`); it is stripped so the
407+
/// result parses as a URL host.
408+
///
409+
/// Limitation: SRV priority/weight (RFC 2782) are not applied here — candidates keep the order the
410+
/// platform resolver returned (Windows `DnsQuery_W` already sorts by priority/weight; hickory and
411+
/// `async_dnssd` do not). Proper priority ordering + weighted selection is a follow-up.
412+
fn srv_records_to_kdc_urls(scheme: &str, records: &[(String, u16)]) -> Vec<String> {
413+
records
414+
.iter()
415+
.map(|(target, port)| format!("{scheme}://{}:{port}", target.trim_end_matches('.')))
416+
.collect()
417+
}
418+
400419
#[allow(unused_variables)]
401420
#[instrument(level = "debug", ret)]
402421
pub(crate) fn detect_kdc_hosts_from_dns(domain: &str) -> Vec<String> {
@@ -412,3 +431,34 @@ pub(crate) fn detect_kdc_hosts_from_dns(domain: &str) -> Vec<String> {
412431
}
413432
}
414433
}
434+
435+
#[cfg(test)]
436+
mod tests {
437+
use super::srv_records_to_kdc_urls;
438+
439+
#[test]
440+
fn builds_scheme_host_port_and_preserves_order() {
441+
let records = vec![("dc1.example.com".to_owned(), 88), ("dc2.example.com".to_owned(), 8888)];
442+
assert_eq!(
443+
srv_records_to_kdc_urls("tcp", &records),
444+
vec![
445+
"tcp://dc1.example.com:88".to_owned(),
446+
"tcp://dc2.example.com:8888".to_owned()
447+
]
448+
);
449+
}
450+
451+
#[test]
452+
fn strips_trailing_dot_from_absolute_names() {
453+
let records = vec![("dc.example.com.".to_owned(), 88)];
454+
assert_eq!(
455+
srv_records_to_kdc_urls("udp", &records),
456+
vec!["udp://dc.example.com:88".to_owned()]
457+
);
458+
}
459+
460+
#[test]
461+
fn empty_input_yields_empty() {
462+
assert!(srv_records_to_kdc_urls("tcp", &[]).is_empty());
463+
}
464+
}

‎src/kdc.rs‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,29 @@ pub(crate) fn detect_kdc_hosts_from_system(domain: &str) -> Vec<String> {
4141
for krb5_conf_path in krb5_conf_paths {
4242
if krb5_conf_path.exists()
4343
&& let Some(krb5_conf) = Krb5Conf::new_from_file(krb5_conf_path)
44-
&& let Some(kdc) = krb5_conf.get_value(vec!["realms", domain, "kdc"])
4544
{
46-
let kdc_url = format!("tcp://{}", kdc.as_str());
47-
return vec![kdc_url];
45+
let kdcs = krb5_conf.get_all_values(vec!["realms", domain, "kdc"]);
46+
if !kdcs.is_empty() {
47+
return krb5_kdc_values_to_hosts(&kdcs);
48+
}
4849
}
4950
}
5051

5152
Vec::new()
5253
}
5354

55+
/// Maps krb5.conf `kdc = ` values to an ordered list of `tcp://` host URLs.
56+
///
57+
/// Per the MIT krb5 spec each `kdc = ` line names exactly one host (optionally `host:port`);
58+
/// multiple KDCs for a realm are expressed as multiple `kdc = ` lines, which [`get_all_values`]
59+
/// returns in order. See <https://web.mit.edu/kerberos/krb5-current/doc/admin/conf_files/krb5_conf.html>.
60+
///
61+
/// [`get_all_values`]: crate::krb::Krb5Conf::get_all_values
62+
#[cfg(not(target_os = "windows"))]
63+
fn krb5_kdc_values_to_hosts(kdcs: &[String]) -> Vec<String> {
64+
kdcs.iter().map(|host| format!("tcp://{host}")).collect()
65+
}
66+
5467
#[instrument(ret, level = "debug")]
5568
pub(crate) fn detect_kdc_hosts(domain: &str) -> Vec<String> {
5669
if let Ok(kdc_url) = env::var(format!("SSPI_KDC_URL_{domain}")) {
@@ -80,8 +93,19 @@ pub fn detect_kdc_host(domain: &str) -> Option<String> {
8093
}
8194

8295
pub fn detect_kdc_url(domain: &str) -> Option<Url> {
83-
let kdc_host = detect_kdc_host(domain)?;
84-
Url::from_str(&kdc_host).ok()
96+
detect_kdc_urls(domain).into_iter().next()
97+
}
98+
99+
/// Resolves the ordered list of candidate KDC URLs for `domain`.
100+
///
101+
/// Same resolution order as [`detect_kdc_hosts`] (env → system store → DNS SRV), but returns every
102+
/// candidate rather than just the first, so callers can fail over to the next KDC when one is
103+
/// unreachable.
104+
pub fn detect_kdc_urls(domain: &str) -> Vec<Url> {
105+
detect_kdc_hosts(domain)
106+
.iter()
107+
.filter_map(|host| Url::from_str(host).ok())
108+
.collect()
85109
}
86110

87111
#[cfg(test)]
@@ -99,4 +123,37 @@ mod tests {
99123
}
100124
}
101125
}
126+
127+
#[cfg(not(target_os = "windows"))]
128+
#[test]
129+
fn krb5_kdc_values_expand_to_one_host_each() {
130+
use super::krb5_kdc_values_to_hosts;
131+
132+
// Single host.
133+
assert_eq!(
134+
krb5_kdc_values_to_hosts(&["dc1.example.com".to_owned()]),
135+
vec!["tcp://dc1.example.com".to_owned()]
136+
);
137+
138+
// Multiple `kdc = ` lines preserve order.
139+
assert_eq!(
140+
krb5_kdc_values_to_hosts(&["dc1.example.com".to_owned(), "dc2.example.com".to_owned()]),
141+
vec!["tcp://dc1.example.com".to_owned(), "tcp://dc2.example.com".to_owned()]
142+
);
143+
144+
// One host per line (MIT spec): a whitespace-separated line is not split.
145+
assert_eq!(
146+
krb5_kdc_values_to_hosts(&["dc1.example.com dc2.example.com".to_owned()]),
147+
vec!["tcp://dc1.example.com dc2.example.com".to_owned()]
148+
);
149+
150+
// An explicit port is preserved.
151+
assert_eq!(
152+
krb5_kdc_values_to_hosts(&["dc1.example.com:8888".to_owned()]),
153+
vec!["tcp://dc1.example.com:8888".to_owned()]
154+
);
155+
156+
// Nothing in, nothing out.
157+
assert!(krb5_kdc_values_to_hosts(&[]).is_empty());
158+
}
102159
}

0 commit comments

Comments
 (0)