Skip to content

Commit f430590

Browse files
authored
feat(kerberos): honour DNS SRV port and fail over across multiple KDCs (#698)
Kerberos clients resolved a single KDC and never failed over: the Windows DNS SRV lookup read only the first record and hardcoded port 88, krb5.conf parsing kept only the first `kdc =` line of a realm, and every message of an exchange re-resolved the KDC independently, so round-robin DNS could bounce one authentication between KDCs. - Honour the DNS SRV port on Windows by walking every record `DnsQuery_W` returns, keeping the SRV ones and using their `wPort`. - Add `detect_kdc_urls()`, which returns every candidate in resolution order (`SSPI_KDC_URL_<REALM>`, krb5.conf, DNS SRV); `detect_kdc_url()` keeps returning the first. - Read every `kdc =` line of a realm from krb5.conf, one host per line as the MIT documentation specifies. - Try candidates in order and advance on any send error; any KDC reply, including a KRB-ERROR, is returned as-is. - Pin the KDC that answered for a realm for the rest of the exchange and re-resolve only when it stops answering. A configured KDC URL stays the sole candidate for the home realm; referral realms are always resolved. SRV priority and weight are not applied yet; see #787.
1 parent c18aa7c commit f430590

6 files changed

Lines changed: 535 additions & 60 deletions

File tree

‎src/dns.rs‎

Lines changed: 87 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,33 @@ 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().cast::<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+
// UNTESTED: `DNS_RECORDA` and `DNS_RECORDW` share one layout (`PSTR` and `PWSTR` are both pointers),
38+
// so the list `DnsQuery_W` types as `DNS_RECORDA` is reinterpreted as `DNS_RECORDW` once and the
39+
// record types tell the truth for the rest of the walk.
40+
// Nothing here runs under test, so when bumping the `windows` crate compare both struct definitions
41+
// in `Win32::NetworkManagement::Dns` field by field.
42+
let mut p_record = p_query_results.cast::<DNS_RECORDW>();
43+
while !p_record.is_null() {
44+
// SAFETY: `p_record` is non-null and points to a valid record of the list `DnsQuery_W`
45+
// returned.
46+
let record = unsafe { *p_record };
47+
48+
if record.wType == DNS_TYPE_SRV.0 {
49+
// SAFETY: `wType == DNS_TYPE_SRV` guarantees the `Srv` union member is active.
50+
let srv = unsafe { record.Data.Srv };
51+
// SAFETY: `pNameTarget` points to a valid, null-terminated UTF-16 string.
52+
if let Ok(name_target) = unsafe { srv.pNameTarget.to_string() } {
53+
records.push((name_target, srv.wPort));
54+
}
55+
}
56+
57+
p_record = record.pNext;
4458
}
4559
}
4660
Err(error) => error!(%error, "DnsQuery_W failed"),
4761
}
4862

49-
// SAFETY: `p_query_results` is not null.
63+
// SAFETY: `DnsFree` tolerates a null pointer.
5064
unsafe {
5165
DnsFree(Some(p_query_results.cast::<c_void>()), DnsFreeRecordList);
5266
}
@@ -136,14 +150,14 @@ cfg_if::cfg_if! {
136150
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);
137151

138152
if !krb_tcp_srv.is_empty() {
139-
return krb_tcp_srv.iter().map(|x| format!("tcp://{x}:88")).collect()
153+
return srv_records_to_kdc_urls("tcp", &krb_tcp_srv)
140154
}
141155

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

145159
if !krb_udp_srv.is_empty() {
146-
return krb_udp_srv.iter().map(|x| format!("udp://{x}:88")).collect()
160+
return srv_records_to_kdc_urls("udp", &krb_udp_srv)
147161
}
148162

149163
Vec::new()
@@ -335,14 +349,16 @@ cfg_if::cfg_if! {
335349
let krb_tcp_srv = dns_query_srv_records(krb_tcp_name);
336350

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

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

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

348364
Vec::new()
@@ -437,23 +453,15 @@ cfg_if::cfg_if! {
437453

438454
if let Some(resolver) = get_dns_resolver(domain) {
439455
if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._tcp.{domain}"))) {
440-
for record in records {
441-
let port = record.port();
442-
let target_name = record.target().to_string();
443-
let target_name = target_name.trim_end_matches('.').to_string();
444-
let kdc_host = format!("tcp://{}:{}", target_name, port);
445-
kdc_hosts.push(kdc_host);
446-
}
456+
let records: Vec<(String, u16)> =
457+
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
458+
kdc_hosts.extend(srv_records_to_kdc_urls("tcp", &records));
447459
}
448460

449461
if let Ok(records) = execute_future(resolver.srv_lookup(format!("_kerberos._udp.{domain}"))) {
450-
for record in records {
451-
let port = record.port();
452-
let target_name = record.target().to_string();
453-
let target_name = target_name.trim_end_matches('.').to_string();
454-
let kdc_host = format!("udp://{}:{}", target_name, port);
455-
kdc_hosts.push(kdc_host);
456-
}
462+
let records: Vec<(String, u16)> =
463+
records.into_iter().map(|r| (r.target().to_string(), r.port())).collect();
464+
kdc_hosts.extend(srv_records_to_kdc_urls("udp", &records));
457465
}
458466
}
459467

@@ -493,6 +501,23 @@ where
493501
}
494502
}
495503

504+
/// Builds `scheme://host:port` KDC URLs from resolved SRV `(target, port)` records.
505+
///
506+
/// DNS returns absolute names with a trailing dot (`dc.example.com.`); it is stripped so the result parses as a URL host.
507+
///
508+
/// Limitation: the SRV priority and weight of [RFC 2782] are not applied, so candidates keep the order the platform
509+
/// resolver returned.
510+
/// Windows `DnsQuery_W` already sorts by priority and weight; hickory and `async_dnssd` do not.
511+
/// Tracked in <https://github.com/Devolutions/sspi-rs/issues/787>.
512+
///
513+
/// [RFC 2782]: https://www.rfc-editor.org/rfc/rfc2782
514+
fn srv_records_to_kdc_urls(scheme: &str, records: &[(String, u16)]) -> Vec<String> {
515+
records
516+
.iter()
517+
.map(|(target, port)| format!("{scheme}://{}:{port}", target.trim_end_matches('.')))
518+
.collect()
519+
}
520+
496521
#[allow(unused_variables)]
497522
#[instrument(level = "debug", ret)]
498523
pub(crate) fn detect_kdc_hosts_from_dns(domain: &str) -> Vec<String> {
@@ -607,3 +632,34 @@ mod apple_srv_tests {
607632
));
608633
}
609634
}
635+
636+
#[cfg(test)]
637+
mod tests {
638+
use super::srv_records_to_kdc_urls;
639+
640+
#[test]
641+
fn builds_scheme_host_port_and_preserves_order() {
642+
let records = vec![("dc1.example.com".to_owned(), 88), ("dc2.example.com".to_owned(), 8888)];
643+
assert_eq!(
644+
srv_records_to_kdc_urls("tcp", &records),
645+
vec![
646+
"tcp://dc1.example.com:88".to_owned(),
647+
"tcp://dc2.example.com:8888".to_owned()
648+
]
649+
);
650+
}
651+
652+
#[test]
653+
fn strips_trailing_dot_from_absolute_names() {
654+
let records = vec![("dc.example.com.".to_owned(), 88)];
655+
assert_eq!(
656+
srv_records_to_kdc_urls("udp", &records),
657+
vec!["udp://dc.example.com:88".to_owned()]
658+
);
659+
}
660+
661+
#[test]
662+
fn empty_input_yields_empty() {
663+
assert!(srv_records_to_kdc_urls("tcp", &[]).is_empty());
664+
}
665+
}

‎src/kdc.rs‎

Lines changed: 61 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 [krb5.conf] § "\[realms\]", 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`] returns in order.
59+
///
60+
/// [krb5.conf]: https://web.mit.edu/kerberos/krb5-current/doc/admin/conf_files/krb5_conf.html
61+
/// [`get_all_values`]: crate::krb::Krb5Conf::get_all_values
62+
#[cfg(not(target_os = "windows"))]
63+
fn krb5_kdc_values_to_hosts(kdcs: &[&str]) -> 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,18 @@ 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_host`] (`SSPI_KDC_URL_<DOMAIN>`, then the system store, then DNS SRV), but
102+
/// returns every candidate rather than just the first, so callers can fail over to the next KDC when one is unreachable.
103+
pub fn detect_kdc_urls(domain: &str) -> Vec<Url> {
104+
detect_kdc_hosts(domain)
105+
.iter()
106+
.filter_map(|host| Url::from_str(host).ok())
107+
.collect()
85108
}
86109

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

0 commit comments

Comments
 (0)