diff --git a/src/CommonLib/ConnectionPoolManager.cs b/src/CommonLib/ConnectionPoolManager.cs
index 61136cc7f..a01bcf8dd 100644
--- a/src/CommonLib/ConnectionPoolManager.cs
+++ b/src/CommonLib/ConnectionPoolManager.cs
@@ -1,8 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.DirectoryServices;
-using System.Runtime.CompilerServices;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
@@ -146,17 +144,19 @@ private string ResolveIdentifier(string identifier) {
//we expect this to fail sometimes
}
- if (LdapUtils.GetDomain(domainName, _ldapConfig, out var domainObject))
- try {
- // TODO: MC - Confirm GetDirectoryEntry is not a Blocking External Call
- if (domainObject.GetDirectoryEntry().ToDirectoryObject().TryGetSecurityIdentifier(out domainSid)) {
- Cache.AddDomainSidMapping(domainName, domainSid);
- return (true, domainSid);
- }
- }
- catch {
- //we expect this to fail sometimes (not sure why, but better safe than sorry)
- }
+ // Controlled replacement for LdapUtils.GetDomain + GetDirectoryEntry. We pass pool: null
+ // because this method is called from inside GetPool -> ResolveIdentifier while resolving
+ // the pool for this same domain; reusing the pool here would reenter GetLdapConnection and
+ // recurse into GetDomainSidFromDomainName. With pool: null, GetDomainInfoStaticAsync falls
+ // through to its direct-LDAP (one-shot LdapConnection) path, which still honors LdapConfig.
+ // The call is sync-over-async to match the sibling pattern in GetLdapConnectionForServer.
+ var (infoOk, info) = LdapUtils
+ .GetDomainInfoStaticAsync(domainName, _ldapConfig, _log)
+ .GetAwaiter().GetResult();
+ if (infoOk && !string.IsNullOrEmpty(info?.DomainSid)) {
+ Cache.AddDomainSidMapping(domainName, info.DomainSid);
+ return (true, info.DomainSid);
+ }
foreach (var name in _translateNames)
try {
diff --git a/src/CommonLib/DomainInfo.cs b/src/CommonLib/DomainInfo.cs
new file mode 100644
index 000000000..493b0e743
--- /dev/null
+++ b/src/CommonLib/DomainInfo.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace SharpHoundCommonLib
+{
+ ///
+ /// Lightweight, transport-agnostic description of an Active Directory domain populated either
+ /// from controlled LDAP queries (honoring ) or, when explicitly opted in
+ /// via , from
+ /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain.
+ ///
+ public sealed class DomainInfo
+ {
+ /// Upper-cased DNS name of the domain (e.g. CONTOSO.LOCAL).
+ public string Name { get; }
+
+ /// Default naming context distinguished name (e.g. DC=contoso,DC=local).
+ public string DistinguishedName { get; }
+
+ /// Upper-cased DNS name of the forest root domain, when known.
+ public string ForestName { get; }
+
+ /// Domain SID (S-1-5-21-...) if resolved, otherwise null.
+ public string DomainSid { get; }
+
+ /// Legacy NetBIOS domain name if resolved from the Partitions container, otherwise null.
+ public string NetBiosName { get; }
+
+ /// DNS hostname of the PDC FSMO role owner if resolved, otherwise null.
+ public string PrimaryDomainController { get; }
+
+ /// DNS hostnames of known domain controllers for this domain.
+ public IReadOnlyList DomainControllers { get; }
+
+ public DomainInfo(
+ string name = null,
+ string distinguishedName = null,
+ string forestName = null,
+ string domainSid = null,
+ string netBiosName = null,
+ string primaryDomainController = null,
+ IReadOnlyList domainControllers = null) {
+ Name = name;
+ DistinguishedName = distinguishedName;
+ ForestName = forestName;
+ DomainSid = domainSid;
+ NetBiosName = netBiosName;
+ PrimaryDomainController = primaryDomainController;
+ DomainControllers = domainControllers ?? Array.Empty();
+ }
+ }
+}
diff --git a/src/CommonLib/Enums/LDAPProperties.cs b/src/CommonLib/Enums/LDAPProperties.cs
index 0bf6b726e..b9ee6f53e 100644
--- a/src/CommonLib/Enums/LDAPProperties.cs
+++ b/src/CommonLib/Enums/LDAPProperties.cs
@@ -96,5 +96,7 @@ public static class LDAPProperties
public const string LockOutObservationWindow = "lockoutobservationwindow";
public const string PrincipalName = "msds-principalname";
public const string GroupType = "grouptype";
+ public const string FSMORoleOwner = "fsmoroleowner";
+ public const string NCName = "ncname";
}
}
diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs
index 753e53bd1..a6b17828d 100644
--- a/src/CommonLib/ILdapUtils.cs
+++ b/src/CommonLib/ILdapUtils.cs
@@ -89,6 +89,25 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName,
/// True if the domain was found, false if not
bool GetDomain(out System.DirectoryServices.ActiveDirectory.Domain domain);
+ ///
+ /// Resolves a for the specified domain using controlled LDAP queries
+ /// that honor the configured (server, port, SSL, auth, signing, cert verification).
+ /// Falls back to System.DirectoryServices.ActiveDirectory.Domain.GetDomain only when
+ /// is enabled.
+ ///
+ /// The domain name to resolve
+ /// A tuple containing success state as well as the populated DomainInfo if successful
+ Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName);
+
+ ///
+ /// Resolves a for the user's current domain using controlled LDAP queries
+ /// that honor the configured . Falls back to
+ /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain only when
+ /// is enabled.
+ ///
+ /// A tuple containing success state as well as the populated DomainInfo if successful
+ Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync();
+
Task<(bool Success, string ForestName)> GetForest(string domain);
///
/// Attempts to resolve an account name to its corresponding typed principal
diff --git a/src/CommonLib/LdapConfig.cs b/src/CommonLib/LdapConfig.cs
index 19e9995d0..6c33adb0a 100644
--- a/src/CommonLib/LdapConfig.cs
+++ b/src/CommonLib/LdapConfig.cs
@@ -15,6 +15,8 @@ public class LdapConfig
public bool DisableCertVerification { get; set; } = false;
public AuthType AuthType { get; set; } = AuthType.Kerberos;
public int MaxConcurrentQueries { get; set; } = 15;
+ public bool AllowFallbackToUncontrolledLdap { get; set; } = false;
+ public string CurrentUserDomain { get; set; } = null;
//Returns the port for connecting to LDAP. Will always respect a user's overridden config over anything else
public int GetPort(bool ssl)
@@ -56,6 +58,10 @@ public override string ToString() {
sb.AppendLine($"ForceSSL: {ForceSSL}");
sb.AppendLine($"AuthType: {AuthType.ToString()}");
sb.AppendLine($"MaxConcurrentQueries: {MaxConcurrentQueries}");
+ sb.AppendLine($"AllowFallbackToUncontrolledLdap: {AllowFallbackToUncontrolledLdap}");
+ if (!string.IsNullOrWhiteSpace(CurrentUserDomain)) {
+ sb.AppendLine($"CurrentUserDomain: {CurrentUserDomain}");
+ }
if (!string.IsNullOrWhiteSpace(Username)) {
sb.AppendLine($"Username: {Username}");
}
diff --git a/src/CommonLib/LdapConnectionPool.cs b/src/CommonLib/LdapConnectionPool.cs
index 4744cb402..fff843d9c 100644
--- a/src/CommonLib/LdapConnectionPool.cs
+++ b/src/CommonLib/LdapConnectionPool.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.DirectoryServices.ActiveDirectory;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
@@ -451,7 +450,7 @@ private async Task SetupLdapQuery(LdapQueryParameters quer
return result;
}
- var (searchRequestSuccess, searchRequest) = CreateSearchRequest(queryParameters, connectionWrapper);
+ var (searchRequestSuccess, searchRequest) = await CreateSearchRequestAsync(queryParameters, connectionWrapper);
if (!searchRequestSuccess) {
result.Success = false;
result.Message = "Failed to create search request";
@@ -492,7 +491,7 @@ public async IAsyncEnumerable> RangedRetrieval(string distinguish
};
var connectionWrapper = connectionResult.ConnectionWrapper;
- var (searchRequestSuccess, searchRequest) = CreateSearchRequest(queryParameters, connectionWrapper);
+ var (searchRequestSuccess, searchRequest) = await CreateSearchRequestAsync(queryParameters, connectionWrapper);
if (!searchRequestSuccess) {
ReleaseConnection(connectionWrapper);
yield return Result.Fail("Failed to create search request");
@@ -685,23 +684,30 @@ private static TimeSpan GetNextBackoff(int retryCount) {
MaxBackoffDelay.TotalSeconds));
}
- private (bool, SearchRequest) CreateSearchRequest(LdapQueryParameters queryParameters,
+ private async Task<(bool, SearchRequest)> CreateSearchRequestAsync(LdapQueryParameters queryParameters,
LdapConnectionWrapper connectionWrapper) {
string basePath;
if (!string.IsNullOrWhiteSpace(queryParameters.SearchBase)) {
basePath = queryParameters.SearchBase;
- }
- else if (!connectionWrapper.GetSearchBase(queryParameters.NamingContext, out basePath)) {
+ } else if (!connectionWrapper.GetSearchBase(queryParameters.NamingContext, out basePath)) {
string tempPath;
if (CallDsGetDcName(queryParameters.DomainName, out var info) && info != null) {
tempPath = Helpers.DomainNameToDistinguishedName(info.Value.DomainName);
connectionWrapper.SaveContext(queryParameters.NamingContext, basePath);
}
- else if (LdapUtils.GetDomain(queryParameters.DomainName, _ldapConfig, out var domainObject)) {
- tempPath = Helpers.DomainNameToDistinguishedName(domainObject.Name);
- }
else {
- return (false, null);
+ // Controlled replacement for LdapUtils.GetDomain + DomainNameToDistinguishedName.
+ // Pass null for the pool: this code runs *inside* an LdapConnectionPool, so
+ // attempting controlled resolution via the pool would reenter us. The static
+ // helper will fall through to the uncontrolled fallback, which itself honors
+ // LdapConfig.AllowFallbackToUncontrolledLdap.
+ var (ok, domainInfo) = await LdapUtils
+ .GetDomainInfoStaticAsync(queryParameters.DomainName, _ldapConfig, _log);
+ if (!ok || string.IsNullOrWhiteSpace(domainInfo?.DistinguishedName)) {
+ return (false, null);
+ }
+
+ tempPath = domainInfo.DistinguishedName;
}
basePath = queryParameters.NamingContext switch {
@@ -873,16 +879,20 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti
}
}
- if (!LdapUtils.GetDomain(_identifier, _ldapConfig, out var domainObject) || domainObject?.Name == null) {
+ // Controlled replacement for LdapUtils.GetDomain. The static helper still honors
+ // LdapConfig.AllowFallbackToUncontrolledLdap for the uncontrolled fallback.
+ var (infoOk, info) =
+ await LdapUtils.GetDomainInfoStaticAsync(_identifier, _ldapConfig, _log);
+ if (!infoOk || string.IsNullOrEmpty(info?.Name)) {
//If we don't get a result here, we effectively have no other ways to resolve this domain, so we'll just have to exit out
_log.LogDebug(
- "Could not get domain object from GetDomain, unable to create ldap connection for domain {Domain}",
+ "Could not resolve domain info, unable to create ldap connection for domain {Domain}",
_identifier);
ExcludedDomains.Add(_identifier);
- return (false, null, "Unable to get domain object for further strategies");
+ return (false, null, "Unable to get domain info for further strategies");
}
- tempDomainName = domainObject.Name.ToUpper().Trim();
+ tempDomainName = info.Name.ToUpper().Trim();
if (!tempDomainName.Equals(_identifier, StringComparison.OrdinalIgnoreCase) &&
await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connectionWrapper4)) {
@@ -892,24 +902,26 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti
return (true, connectionWrapper4, "");
}
- var primaryDomainController = domainObject.PdcRoleOwner.Name;
- var portConnectionResult =
- await CreateLDAPConnectionWithPortCheck(primaryDomainController, globalCatalog);
- if (portConnectionResult.success) {
- _log.LogDebug(
- "Successfully created ldap connection for domain: {Domain} using strategy 5 with to pdc {Server}",
- _identifier, primaryDomainController);
- return (true, portConnectionResult.connection, "");
+ if (!string.IsNullOrEmpty(info.PrimaryDomainController)) {
+ var primaryDomainController = info.PrimaryDomainController;
+ var portConnectionResult =
+ await CreateLDAPConnectionWithPortCheck(primaryDomainController, globalCatalog);
+ if (portConnectionResult.success) {
+ _log.LogDebug(
+ "Successfully created ldap connection for domain: {Domain} using strategy 5 with to pdc {Server}",
+ _identifier, primaryDomainController);
+ return (true, portConnectionResult.connection, "");
+ }
}
- // Blocking External Call - Possible on domainObject.DomainControllers as it calls DsGetDcNameWrapper
- foreach (DomainController dc in domainObject.DomainControllers) {
- portConnectionResult =
- await CreateLDAPConnectionWithPortCheck(dc.Name, globalCatalog);
+ foreach (var dcName in info.DomainControllers) {
+ if (string.IsNullOrEmpty(dcName)) continue;
+ var portConnectionResult =
+ await CreateLDAPConnectionWithPortCheck(dcName, globalCatalog);
if (portConnectionResult.success) {
_log.LogDebug(
- "Successfully created ldap connection for domain: {Domain} using strategy 6 with to pdc {Server}",
- _identifier, primaryDomainController);
+ "Successfully created ldap connection for domain: {Domain} using strategy 6 to dc {Server}",
+ _identifier, dcName);
return (true, portConnectionResult.connection, "");
}
}
diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs
index 2ce52dd3e..684a17600 100644
--- a/src/CommonLib/LdapUtils.cs
+++ b/src/CommonLib/LdapUtils.cs
@@ -4,6 +4,7 @@
using System.DirectoryServices;
using System.DirectoryServices.AccountManagement;
using System.DirectoryServices.ActiveDirectory;
+using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
using System.Net.Sockets;
@@ -31,12 +32,21 @@ namespace SharpHoundCommonLib {
public class LdapUtils : ILdapUtils {
//This cache is indexed by domain sid
private static ConcurrentDictionary _domainCache = new();
+ private static ConcurrentDictionary _domainInfoCache =
+ new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentHashSet _domainControllers = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentHashSet _unresolvablePrincipals = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary DomainToForestCache =
new(StringComparer.OrdinalIgnoreCase);
+ // Single-shot cache for the uncontrolled Domain.GetDomain() hint tier in
+ // ResolveEffectiveDomainHint. null = not attempted; "" = attempted and failed; otherwise the
+ // resolved domain name. Guarded by _uncontrolledGetDomainHintLock because the underlying RPC
+ // is slow and we only want it to fire once per process. Reset from ResetUtils.
+ private static string _uncontrolledGetDomainHint;
+ private static readonly object _uncontrolledGetDomainHintLock = new();
+
private static readonly ConcurrentDictionary
SeenWellKnownPrincipals = new();
@@ -52,7 +62,7 @@ private readonly ConcurrentDictionary
// Metrics
private readonly IMetricRouter _metric;
-
+
private readonly ILogger _log;
private readonly IPortScanner _portScanner;
private readonly NativeMethods _nativeMethods;
@@ -280,8 +290,9 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
if (!securityIdentifier.Equals("S-1-5-9", StringComparison.OrdinalIgnoreCase)) {
var tempDomain = domain;
- if (GetDomain(tempDomain, out var domainObject) && domainObject.Name != null) {
- tempDomain = domainObject.Name;
+ if (await GetDomainInfoAsync(tempDomain) is (true, var domainInfo) &&
+ !string.IsNullOrEmpty(domainInfo?.Name)) {
+ tempDomain = domainInfo.Name;
}
return ($"{tempDomain}-{securityIdentifier}".ToUpper(), tempDomain);
@@ -301,15 +312,10 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
return (true, cachedForest);
}
- if (GetDomain(domain, out var domainObject)) {
- try {
- var forestName = domainObject.Forest.Name.ToUpper();
- DomainToForestCache.TryAdd(domain, forestName);
- return (true, forestName);
- }
- catch {
- //pass
- }
+ if (await GetDomainInfoAsync(domain) is (true, var domainInfo) &&
+ !string.IsNullOrEmpty(domainInfo?.ForestName)) {
+ DomainToForestCache.TryAdd(domain, domainInfo.ForestName);
+ return (true, domainInfo.ForestName);
}
var (success, forest) = await GetForestFromLdap(domain);
@@ -395,12 +401,13 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
}
private async Task<(bool Success, string DomainName)> ConvertDomainSidToDomainNameFromLdap(string domainSid) {
- if (!GetDomain(out var domain) || domain?.Name == null) {
+ var (domainOk, domainInfo) = await GetDomainInfoAsync();
+ if (!domainOk || string.IsNullOrEmpty(domainInfo?.Name)) {
return (false, string.Empty);
}
var result = await Query(new LdapQueryParameters {
- DomainName = domain.Name,
+ DomainName = domainInfo.Name,
Attributes = new[] { LDAPProperties.DistinguishedName },
GlobalCatalog = true,
LDAPFilter = new LdapFilter().AddDomains(CommonFilters.SpecificSID(domainSid)).GetFilter()
@@ -411,7 +418,7 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
}
result = await Query(new LdapQueryParameters {
- DomainName = domain.Name,
+ DomainName = domainInfo.Name,
Attributes = new[] { LDAPProperties.DistinguishedName, LDAPProperties.Name },
GlobalCatalog = true,
LDAPFilter = new LdapFilter().AddFilter("(objectclass=trusteddomain)", true)
@@ -423,7 +430,7 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
}
result = await Query(new LdapQueryParameters {
- DomainName = domain.Name,
+ DomainName = domainInfo.Name,
Attributes = new[] { LDAPProperties.DistinguishedName },
LDAPFilter = new LdapFilter().AddFilter("(objectclass=domaindns)", true)
.AddFilter(CommonFilters.SpecificSID(domainSid), true).GetFilter()
@@ -452,17 +459,13 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
//we expect this to fail sometimes
}
- if (GetDomain(domainName, out var domainObject))
- try {
- var entry = domainObject.GetDirectoryEntry().ToDirectoryObject();
- if (entry.TryGetSecurityIdentifier(out domainSid)) {
- Cache.AddDomainSidMapping(domainName, domainSid);
- return (true, domainSid);
- }
- }
- catch {
- //we expect this to fail sometimes (not sure why, but better safe than sorry)
- }
+ // Replaces the legacy GetDomain(out Domain) + GetDirectoryEntry block with a
+ // controlled lookup via the connection pool.
+ if (await GetDomainInfoAsync(domainName) is (true, var domainInfo) &&
+ !string.IsNullOrEmpty(domainInfo?.DomainSid)) {
+ Cache.AddDomainSidMapping(domainName, domainInfo.DomainSid);
+ return (true, domainInfo.DomainSid);
+ }
foreach (var name in _translateNames)
try {
@@ -499,6 +502,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame
///
///
public bool GetDomain(string domainName, out Domain domain) {
+ if (!_ldapConfig.AllowFallbackToUncontrolledLdap) {
+ _log.LogDebug(
+ "GetDomain(\"{Name}\", out Domain) short-circuited: AllowFallbackToUncontrolledLdap is disabled",
+ domainName);
+ domain = null;
+ return false;
+ }
+
var cacheKey = domainName ?? _nullCacheKey;
if (_domainCache.TryGetValue(cacheKey, out domain)) return true;
@@ -529,6 +540,14 @@ public bool GetDomain(string domainName, out Domain domain) {
}
public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domain domain) {
+ if (ldapConfig == null || !ldapConfig.AllowFallbackToUncontrolledLdap) {
+ Logging.Logger.LogDebug(
+ "Static GetDomain(\"{DomainName}\") short-circuited: AllowFallbackToUncontrolledLdap is disabled",
+ domainName);
+ domain = null;
+ return false;
+ }
+
if (_domainCache.TryGetValue(domainName, out domain)) return true;
try {
@@ -566,6 +585,13 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai
///
///
public bool GetDomain(out Domain domain) {
+ if (!_ldapConfig.AllowFallbackToUncontrolledLdap) {
+ _log.LogDebug(
+ "GetDomain(out Domain) short-circuited: AllowFallbackToUncontrolledLdap is disabled");
+ domain = null;
+ return false;
+ }
+
if (_domainCache.TryGetValue(_nullCacheKey, out domain)) return true;
try {
@@ -724,7 +750,7 @@ public bool GetDomain(out Domain domain) {
_log.LogTrace("CheckPort returned false for {HostName}.", hostname);
return (false, default);
}
-
+
// Blocking External Call
var result = await _callNetWkstaGetInfoAdaptiveTimeout.ExecuteNetAPIWithTimeout((_) => _nativeMethods.CallNetWkstaGetInfo(hostname));
@@ -1105,42 +1131,970 @@ public void SetLdapConfig(LdapConfig config) {
//pass
}
- if (GetDomain(domain, out var domainObj)) {
+ // Controlled replacement for the old GetDomain(out Domain) + DomainNameToDistinguishedName
+ // block. DomainInfo.DistinguishedName is the default naming context DN; Configuration and
+ // Schema NCs are constructed from it the same way the old path constructed them.
+ if (await GetDomainInfoAsync(domain) is (true, var domainInfo) &&
+ !string.IsNullOrWhiteSpace(domainInfo?.DistinguishedName)) {
+ var searchBase = context switch {
+ NamingContext.Configuration => $"CN=Configuration,{domainInfo.DistinguishedName}",
+ NamingContext.Schema => $"CN=Schema,CN=Configuration,{domainInfo.DistinguishedName}",
+ NamingContext.Default => domainInfo.DistinguishedName,
+ _ => throw new ArgumentOutOfRangeException()
+ };
+
+ return (true, searchBase);
+ }
+
+ return (false, default);
+ }
+
+ ///
+ /// Resolves a for the specified domain, preferring a controlled
+ /// LDAP path that honors the configured (server, port, SSL,
+ /// AuthType, signing, cert verification, credentials).
+ ///
+ ///
+ /// Resolution order:
+ ///
+ /// - Static cache lookup keyed by (or when null).
+ /// - Controlled LDAP resolution via the connection pool (see ).
+ /// - Uncontrolled fallback via System.DirectoryServices.ActiveDirectory.Domain.GetDomain,
+ /// gated on (see ).
+ ///
+ /// Successful results from either path are cached for the lifetime of the process (until
+ /// is invoked).
+ ///
+ public async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) {
+ // Null domain names are stored under a per-instance sentinel to match the existing
+ // _domainCache null-key convention used by the legacy GetDomain overloads.
+ var cacheKey = domainName ?? _nullCacheKey;
+ if (_domainInfoCache.TryGetValue(cacheKey, out var cached)) {
+ return (true, cached);
+ }
+
+ // Preferred path: every LDAP call made here flows through LdapConnectionPool and
+ // therefore honors every flag on LdapConfig.
+ var (controlledOk, controlledInfo) = await ResolveDomainInfoControlledAsync(domainName);
+ if (controlledOk) {
+ _domainInfoCache.TryAdd(cacheKey, controlledInfo);
+ return (true, controlledInfo);
+ }
+
+ // Resolve the effective domain hint so the remaining tiers can still operate when the
+ // caller passed null (netonly / workgroup hosts rely on LdapConfig.CurrentUserDomain here).
+ var effectiveDomain = ResolveEffectiveDomainHint(domainName);
+
+ // Secondary controlled path: bind directly to the domain name with a one-shot
+ // LdapConnection honoring LdapConfig. Tried before ADSI because this tier is the
+ // only one that can express fine-grained LdapConfig.AuthType and
+ // LdapConfig.DisableCertVerification - running ADSI first would silently ignore
+ // those flags whenever the ADSI bind happened to succeed.
+ if (!string.IsNullOrWhiteSpace(effectiveDomain)) {
+ var (directOk, directInfo) =
+ await TryResolveDomainInfoViaDirectLdapAsync(effectiveDomain, _ldapConfig, _log);
+ if (directOk) {
+ _domainInfoCache.TryAdd(cacheKey, directInfo);
+ return (true, directInfo);
+ }
+ }
+
+ // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding invokes
+ // DsGetDcName internally, so NetBIOS short names and domains without a direct DNS
+ // A record still bind here after the raw-LDAP tier above could not resolve them.
+ if (!string.IsNullOrWhiteSpace(effectiveDomain)) {
+ var (adsiOk, adsiInfo) =
+ await TryResolveDomainInfoViaDirectoryEntryAsync(effectiveDomain, _ldapConfig, _log);
+ if (adsiOk) {
+ _domainInfoCache.TryAdd(cacheKey, adsiInfo);
+ return (true, adsiInfo);
+ }
+ }
+
+ // Opt-in fallback. TryGetDomainInfoViaUncontrolledFallback short-circuits when
+ // AllowFallbackToUncontrolledLdap is disabled, so when the flag is off this call is a no-op.
+ if (TryGetDomainInfoViaUncontrolledFallback(effectiveDomain, _ldapConfig, _log, out var fallbackInfo)) {
+ _domainInfoCache.TryAdd(cacheKey, fallbackInfo);
+ return (true, fallbackInfo);
+ }
+
+ return (false, null);
+ }
+
+ ///
+ /// Convenience overload that resolves a for the user's current
+ /// domain. Equivalent to calling with null.
+ ///
+ public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync() {
+ return GetDomainInfoAsync(null);
+ }
+
+ ///
+ /// Static counterpart to intended for internal
+ /// consumers that cannot hold an instance (notably
+ /// and , which are
+ /// themselves pieces of the connection infrastructure and can't reenter it transparently).
+ ///
+ /// The target domain. Must be non-empty; this entry point does not resolve a default.
+ /// Config used to honor the gate and to build fallback credentials.
+ /// Logger used for debug-level diagnostics on failure paths.
+ ///
+ /// Results from both paths are stored in the same static
+ /// used by the instance overload, so a successful lookup here also benefits later
+ /// calls on the same process.
+ ///
+ internal static async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoStaticAsync(
+ string domainName, LdapConfig config, ILogger log = null) {
+ // Unlike the instance overload, the static entry point does not fabricate a current-domain
+ // hint - its callers always know which domain they are asking about.
+ if (string.IsNullOrWhiteSpace(domainName)) {
+ return (false, null);
+ }
+
+ if (_domainInfoCache.TryGetValue(domainName, out var cached)) {
+ return (true, cached);
+ }
+
+ // Secondary controlled path: a one-shot LdapConnection bound directly to the domain
+ // name, honoring LdapConfig. Tried before ADSI because this tier is the only one
+ // that can express fine-grained LdapConfig.AuthType and LdapConfig.DisableCertVerification.
+ var (directOk, directInfo) = await TryResolveDomainInfoViaDirectLdapAsync(domainName, config, log);
+ if (directOk) {
+ _domainInfoCache.TryAdd(domainName, directInfo);
+ return (true, directInfo);
+ }
+
+ // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding handles
+ // NetBIOS short names and domains whose DNS layout does not expose an A record on
+ // the domain name itself, at the cost of not honoring every LdapConfig flag.
+ var (adsiOk, adsiInfo) = await TryResolveDomainInfoViaDirectoryEntryAsync(domainName, config, log);
+ if (adsiOk) {
+ _domainInfoCache.TryAdd(domainName, adsiInfo);
+ return (true, adsiInfo);
+ }
+
+ if (TryGetDomainInfoViaUncontrolledFallback(domainName, config, log, out var fallbackInfo)) {
+ _domainInfoCache.TryAdd(domainName, fallbackInfo);
+ return (true, fallbackInfo);
+ }
+
+ return (false, null);
+ }
+
+ ///
+ /// Instance-level adapter over that supplies
+ /// this utils' connection pool and logger, and substitutes a current-domain hint when
+ /// is null/empty.
+ ///
+ ///
+ /// The hint is taken from when set, otherwise
+ /// from . The config override is the only workable
+ /// hint in netonly contexts (runas /netonly) and on workgroup machines, where the
+ /// OS API reports the local machine name rather than the target AD domain. If neither is
+ /// usable the connection pool will fail to resolve the hint and this method returns
+ /// (false, null) so the orchestrator can fall through to the uncontrolled fallback.
+ ///
+ private Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoControlledAsync(string domainName) {
+ var hint = ResolveEffectiveDomainHint(domainName);
+ if (string.IsNullOrWhiteSpace(hint)) {
+ return Task.FromResult<(bool, DomainInfo)>((false, null));
+ }
+
+ return ResolveDomainInfoControlledAsyncCore(hint, _connectionPool, _log);
+ }
+
+ ///
+ /// Resolves the domain name to use when the caller did not supply one. Walks a five-step
+ /// preference order designed to maximize resolution success without paying an RPC on the
+ /// common path.
+ ///
+ ///
+ /// Order of preference:
+ ///
+ /// - Explicit — caller-supplied; always wins.
+ /// - — deterministic escape hatch for any
+ /// scenario where the OS-provided hint is wrong. Preferred over the uncontrolled tier below
+ /// because it's free and doesn't require opting into uncontrolled calls.
+ /// - , only when it differs from
+ /// . The env var returning the machine name is a
+ /// reliable signal that the primary token carries no domain identity (netonly, workgroup,
+ /// LocalSystem with no stored creds). In every other case - normal interactive logons,
+ /// regular runas, service accounts in a joined machine's domain - the env var is
+ /// correct and this branch short-circuits the rest of the resolver at zero cost.
+ /// - Uncontrolled DC-locator via Domain.GetDomain (see
+ /// ). Only fires when
+ /// is on. Issues
+ /// Domain.GetDomain(new DirectoryContext(Domain)) with no explicit credentials or
+ /// target, letting the SDS/DsGetDcName stack resolve the current user's domain from the
+ /// thread's outbound authentication context. In runas /netonly that context is the
+ /// alt credential (not the local primary token that step 3 failed on), so this recovers
+ /// the real target domain. Result cached process-wide after the first successful attempt.
+ /// - Last-resort even when it equals the
+ /// machine name. Downstream tiers will almost certainly fail to bind against this, but
+ /// returning the env var here keeps behavior identical to the pre-change code for users who
+ /// haven't opted into the uncontrolled fallback.
+ ///
+ ///
+ internal string ResolveEffectiveDomainHint(string domainName) {
+ // 1. Explicit argument wins unconditionally.
+ if (!string.IsNullOrWhiteSpace(domainName))
+ return domainName;
+
+ // 2. Config-provided override. Cheap, deterministic, no network I/O.
+ if (!string.IsNullOrWhiteSpace(_ldapConfig?.CurrentUserDomain))
+ return _ldapConfig.CurrentUserDomain;
+
+ // 3. Env var, but only when it actually carries a domain identity. UserDomainName ==
+ // MachineName is the canonical signal for "no domain on the primary token" and covers
+ // netonly, workgroup, and LocalSystem cases without false positives on domain-joined
+ // setups (where the two always differ).
+ var envDomain = Environment.UserDomainName;
+ if (!string.IsNullOrWhiteSpace(envDomain) &&
+ !string.Equals(envDomain, Environment.MachineName, StringComparison.OrdinalIgnoreCase)) {
+ return envDomain;
+ }
+
+ // 4. Opt-in uncontrolled DC-locator. Only pays the RPC when step 3 determined the env var
+ // is unusable AND the caller has explicitly allowed uncontrolled calls. No credentials
+ // are passed - the SDS stack uses the thread's outbound auth context, which in netonly
+ // is the alt credential rather than the local primary token.
+ if (TryResolveHintViaUncontrolledGetDomain(_ldapConfig, _log, out var credDomain)) {
+ return credDomain;
+ }
+
+ // 5. Preserve pre-change behavior: return the env var even if it's the machine name.
+ // Lets downstream tiers decide how to fail.
+ return envDomain;
+ }
+
+ ///
+ /// Uncontrolled hint resolver that invokes Domain.GetDomain(new DirectoryContext(Domain))
+ /// with no explicit credentials or target name, letting the SDS/DsGetDcName stack resolve the
+ /// domain from the current thread's outbound authentication context. Gated behind
+ /// because it makes an unmanaged
+ /// DC-locator RPC that bypasses every other flag.
+ ///
+ ///
+ /// Complements step 3 in : that step consults
+ /// , which reads the process's primary token. In
+ /// runas /netonly the primary token is the local machine, so that value is useless;
+ /// however the LSA still attaches the alt credential to the thread for outbound network auth,
+ /// and DsGetDcName uses that context to locate a DC for the alt credential's actual
+ /// domain. Passing no username/password here is deliberate -
+ /// is not guaranteed to be a UPN or downlevel name, so
+ /// supplying it to could misdirect the locator; the
+ /// credential-less form instead lets Windows use whatever authentication context is already
+ /// bound to the thread.
+ ///
+ /// Result is cached process-wide in under
+ /// . The lock serializes the RPC so it fires at
+ /// most once per cycle even under concurrent callers. A stored empty
+ /// string is a "tried and failed" sentinel so repeat callers short-circuit without retrying.
+ ///
+ ///
+ private static bool TryResolveHintViaUncontrolledGetDomain(
+ LdapConfig config, ILogger log, out string domainName) {
+ domainName = null;
+
+ // Hard gate: uncontrolled calls require explicit opt-in.
+ if (config == null || !config.AllowFallbackToUncontrolledLdap) return false;
+
+ lock (_uncontrolledGetDomainHintLock) {
+ // Previously resolved or previously failed - either way, no new RPC.
+ if (_uncontrolledGetDomainHint != null) {
+ if (_uncontrolledGetDomainHint.Length == 0) return false;
+ domainName = _uncontrolledGetDomainHint;
+ return true;
+ }
+
+ try {
+ // No name, no credentials: SDS resolves via the thread's outbound auth context,
+ // which is what surfaces the alt-creds domain under runas /netonly.
+ var ctx = new DirectoryContext(DirectoryContextType.Domain);
+ var domain = Domain.GetDomain(ctx);
+ var name = domain?.Name;
+ if (!string.IsNullOrEmpty(name)) {
+ _uncontrolledGetDomainHint = name;
+ domainName = name;
+ return true;
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e,
+ "TryResolveHintViaUncontrolledGetDomain: Domain.GetDomain failed");
+ }
+
+ // Negative-cache the failure so repeat cache-miss paths don't re-issue the RPC.
+ _uncontrolledGetDomainHint = string.Empty;
+ return false;
+ }
+ }
+
+ ///
+ /// Core controlled-LDAP resolver. Builds a using only queries
+ /// that flow through the supplied , so every call
+ /// honors the attached to that pool.
+ ///
+ ///
+ /// Work performed, in order:
+ ///
+ /// - Acquire a pooled connection to . The wrapper's cached rootDSE
+ /// supplies defaultNamingContext and configurationNamingContext without an extra round-trip.
+ /// - Base search on the domain NC (objectClass=*) for objectSid (domain SID),
+ /// rootDomainNamingContext (forest root, used to compute )
+ /// and fSMORoleOwner (PDC FSMO owner; stored as a DN to the NTDS Settings object).
+ /// - If the PDC owner DN is resolved, a follow-up Base search on the owner's parent
+ /// server object retrieves dNSHostName to populate .
+ /// - OneLevel search under CN=Partitions,<configNc> filtered by nCName
+ /// retrieves the legacy NetBIOS domain name from the matching crossRef object.
+ /// - Subtree search on the domain NC for domain controllers
+ /// (userAccountControl:1.2.840.113556.1.4.803:=8192) collects DNS hostnames into
+ /// ; if the PDC lookup failed, the first DC is used as a fallback.
+ ///
+ /// All follow-up queries after the initial NC read are wrapped in try/catch and only
+ /// populate optional fields - a failure here still returns a partially-filled
+ /// . A failure
+ /// to acquire the connection or to read the default NC is fatal and returns (false, null).
+ ///
+ private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoControlledAsyncCore(
+ string domainName, ConnectionPoolManager pool, ILogger log) {
+ if (string.IsNullOrWhiteSpace(domainName) || pool == null) {
+ return (false, null);
+ }
+
+ // Acquire a pooled connection to harvest rootDSE-derived naming contexts.
+ var (ok, wrapper, _) = await pool.GetLdapConnection(domainName, false);
+ if (!ok || wrapper == null) {
+ return (false, null);
+ }
+
+ // GetSearchBase reads from the wrapper's cached rootDSE entry populated when the
+ // connection was established - no additional LDAP traffic is issued here. We release
+ // the connection immediately so subsequent Query(...) calls can reuse it from the pool.
+ string defaultNc;
+ string configNc;
+ try {
+ wrapper.GetSearchBase(NamingContext.Default, out defaultNc);
+ wrapper.GetSearchBase(NamingContext.Configuration, out configNc);
+ }
+ finally {
+ pool.ReleaseConnection(wrapper);
+ }
+
+ if (string.IsNullOrWhiteSpace(defaultNc)) {
+ return (false, null);
+ }
+
+ // Canonical name is always derivable from the default NC (e.g. DC=contoso,DC=local -> CONTOSO.LOCAL).
+ var name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper();
+ string domainSid = null;
+ string forestName = null;
+ string primaryDomainController = null;
+ string netBiosName = null;
+ IReadOnlyList domainControllers = null;
+
+ // Base search on the domain NC harvests the domain SID, forest root NC, and PDC FSMO owner DN
+ // in a single round-trip.
+ try {
+ var baseRes = await pool.Query(new LdapQueryParameters {
+ DomainName = domainName,
+ SearchBase = defaultNc,
+ SearchScope = SearchScope.Base,
+ LDAPFilter = "(objectClass=*)",
+ Attributes = new[] {
+ LDAPProperties.ObjectSID,
+ LDAPProperties.RootDomainNamingContext,
+ LDAPProperties.FSMORoleOwner,
+ },
+ }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync();
+
+ if (baseRes.IsSuccess) {
+ // objectSid on the domain NC itself is the domain SID (S-1-5-21-a-b-c).
+ if (baseRes.Value.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) {
+ domainSid = sid.ToUpper();
+ }
+
+ // rootDomainNamingContext points at the forest root domain's NC even when queried
+ // against a child domain, giving us the forest name without a separate GC lookup.
+ if (baseRes.Value.TryGetProperty(LDAPProperties.RootDomainNamingContext, out var rootNc) &&
+ !string.IsNullOrEmpty(rootNc)) {
+ forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper();
+ }
+
+ // fSMORoleOwner on the domain NC is a DN to the NTDS Settings object of the PDC,
+ // e.g. "CN=NTDS Settings,CN=DC01,CN=Servers,CN=Default-First-Site-Name,...".
+ // Strip the NTDS Settings RDN to get the Server object DN, then read its dNSHostName.
+ if (baseRes.Value.TryGetProperty(LDAPProperties.FSMORoleOwner, out var fsmoOwner) &&
+ TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) {
+ var pdcRes = await pool.Query(new LdapQueryParameters {
+ DomainName = domainName,
+ SearchBase = serverDn,
+ SearchScope = SearchScope.Base,
+ LDAPFilter = "(objectClass=*)",
+ Attributes = new[] { LDAPProperties.DNSHostName },
+ }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync();
+
+ if (pdcRes.IsSuccess &&
+ pdcRes.Value.TryGetProperty(LDAPProperties.DNSHostName, out var pdcName)) {
+ primaryDomainController = pdcName;
+ }
+ }
+ }
+ }
+ catch (Exception ex) {
+ log?.LogDebug(ex, "ResolveDomainInfoControlled: base query failed for {Domain}", domainName);
+ }
+
+ // NetBIOS name lives on the crossRef entry whose nCName matches the domain NC. Cross-references
+ // live under CN=Partitions in the configuration NC, so this query only runs if we read the
+ // configuration NC above.
+ if (!string.IsNullOrWhiteSpace(configNc)) {
+ try {
+ var nbRes = await pool.Query(new LdapQueryParameters {
+ DomainName = domainName,
+ SearchBase = $"CN=Partitions,{configNc}",
+ SearchScope = SearchScope.OneLevel,
+ LDAPFilter = $"(&(objectClass=crossRef)({LDAPProperties.NCName}={defaultNc}))",
+ Attributes = new[] { LDAPProperties.NetbiosName },
+ }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync();
+
+ if (nbRes.IsSuccess && nbRes.Value.TryGetProperty(LDAPProperties.NetbiosName, out var nb)) {
+ netBiosName = nb;
+ }
+ }
+ catch (Exception ex) {
+ log?.LogDebug(ex, "ResolveDomainInfoControlled: netbios lookup failed for {Domain}", domainName);
+ }
+ }
+
+ // DC enumeration uses the canonical "DC object = computer with SERVER_TRUST_ACCOUNT in UAC"
+ // bit test (0x2000). This is the same filter used elsewhere in the project via CommonFilters.
+ try {
+ var dcs = new List();
+ var dcEnum = pool.Query(new LdapQueryParameters {
+ DomainName = domainName,
+ SearchBase = defaultNc,
+ LDAPFilter = CommonFilters.DomainControllers,
+ Attributes = new[] { LDAPProperties.DNSHostName },
+ });
+ await foreach (var dcRes in dcEnum) {
+ if (dcRes.IsSuccess &&
+ dcRes.Value.TryGetProperty(LDAPProperties.DNSHostName, out var dcName) &&
+ !string.IsNullOrEmpty(dcName)) {
+ dcs.Add(dcName);
+ }
+ }
+
+ domainControllers = dcs;
+ // Last-resort PDC: if the FSMO resolution above failed, any DC is a reasonable
+ // fallback target for callers that just want a working DC name.
+ if (string.IsNullOrEmpty(primaryDomainController) && dcs.Count > 0) {
+ primaryDomainController = dcs[0];
+ }
+ }
+ catch (Exception ex) {
+ log?.LogDebug(ex, "ResolveDomainInfoControlled: DC enumeration failed for {Domain}", domainName);
+ }
+
+ return (true, new DomainInfo(
+ name: name,
+ distinguishedName: defaultNc,
+ forestName: forestName,
+ domainSid: domainSid,
+ netBiosName: netBiosName,
+ primaryDomainController: primaryDomainController,
+ domainControllers: domainControllers));
+ }
+
+ ///
+ /// Strips the leading CN=NTDS Settings, RDN from a FSMO role owner DN, yielding the
+ /// DN of the parent Server object. AD stores FSMO ownership as a DN pointing at the NTDS
+ /// Settings object nested under the server, but the dNSHostName attribute lives on
+ /// the server one level up.
+ ///
+ /// True if the prefix was found and a non-empty parent DN was produced.
+ internal static bool TryStripNtdsSettingsPrefix(string fsmoRoleOwnerDn, out string serverDn) {
+ serverDn = null;
+ if (string.IsNullOrEmpty(fsmoRoleOwnerDn)) return false;
+ const string prefix = "CN=NTDS Settings,";
+ if (!fsmoRoleOwnerDn.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
+ serverDn = fsmoRoleOwnerDn.Substring(prefix.Length);
+ return !string.IsNullOrEmpty(serverDn);
+ }
+
+ ///
+ /// Uncontrolled fallback that populates a by calling
+ /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain.
+ ///
+ ///
+ /// This path is intentionally opt-in: it returns false immediately unless
+ /// is set. When enabled, it mirrors
+ /// the exact construction used by the legacy
+ /// LdapUtils.GetDomain overloads so behavior is bit-identical to the pre-change code.
+ ///
+ /// The call into Domain.GetDomain does not honor any flag
+ /// beyond the username/password branches - server, port, SSL, signing, and cert-verification
+ /// settings are all bypassed because that API performs its own DC discovery via the native
+ /// DS RPC stack.
+ ///
+ ///
+ /// Every optional property access (Forest, PdcRoleOwner, DomainControllers,
+ /// GetDirectoryEntry) is wrapped in its own try/catch because each of these can
+ /// blocking-dial the network or attempt delegated auth and may fail independently even
+ /// when the top-level Domain was obtained successfully.
+ ///
+ ///
+ private static bool TryGetDomainInfoViaUncontrolledFallback(string domainName, LdapConfig config,
+ ILogger log, out DomainInfo info) {
+ info = null;
+ // Hard gate - when the flag is off this method is a no-op regardless of what the
+ // surrounding LDAP config looks like.
+ if (config == null || !config.AllowFallbackToUncontrolledLdap) {
+ return false;
+ }
+
+ try {
+ // Matches the DirectoryContext construction in the legacy GetDomain overloads so
+ // enabling this fallback yields identical results to the pre-change code path.
+ DirectoryContext context;
+ if (config.Username != null)
+ context = domainName != null
+ ? new DirectoryContext(DirectoryContextType.Domain, domainName, config.Username,
+ config.Password)
+ : new DirectoryContext(DirectoryContextType.Domain, config.Username, config.Password);
+ else
+ context = domainName != null
+ ? new DirectoryContext(DirectoryContextType.Domain, domainName)
+ : new DirectoryContext(DirectoryContextType.Domain);
+
+ // Blocking External Call
+ var domain = Domain.GetDomain(context);
+ if (domain == null) {
+ return false;
+ }
+
+ var name = domain.Name?.ToUpper();
+ var distinguishedName = !string.IsNullOrEmpty(domain.Name)
+ ? Helpers.DomainNameToDistinguishedName(domain.Name)
+ : null;
+ string forestName = null;
+ string primaryDomainController = null;
+ string domainSid = null;
+ IReadOnlyList domainControllers = null;
+
+ // Forest lookup triggers a separate bind under the hood; swallow any failure and
+ // leave ForestName null rather than losing the rest of the DomainInfo.
try {
- var entry = domainObj.GetDirectoryEntry().ToDirectoryObject();
- if (entry.TryGetProperty(property, out var searchBase)) {
- return (true, searchBase);
+ forestName = domain.Forest?.Name?.ToUpper();
+ }
+ catch {
+ //pass
+ }
+
+ // PdcRoleOwner.Name is the DNS hostname of the PDC. Separately guarded because
+ // it performs its own RPC lookup.
+ try {
+ primaryDomainController = domain.PdcRoleOwner?.Name;
+ }
+ catch {
+ //pass
+ }
+
+ // DomainControllers enumeration discovers DCs via DsGetDcName; each property
+ // access on a returned controller can also fault independently.
+ try {
+ var dcs = new List();
+ foreach (DomainController dc in domain.DomainControllers) {
+ try {
+ if (!string.IsNullOrEmpty(dc?.Name)) dcs.Add(dc.Name);
+ }
+ catch {
+ //pass
+ }
}
+
+ domainControllers = dcs;
}
catch {
//pass
}
- var name = domainObj.Name;
- if (!string.IsNullOrWhiteSpace(name)) {
- var tempPath = Helpers.DomainNameToDistinguishedName(name);
+ // GetDirectoryEntry binds to the domain NC via SDS and reads objectSid. Cheapest
+ // way to get the domain SID from an already-resolved Domain object without another
+ // DirectoryContext round-trip. The raw DirectoryEntry inherits only the
+ // DirectoryContext credentials, so apply the LdapConfig transport/auth flags
+ // (matching Helpers.CreateDirectoryEntry) before the first property access forces
+ // the bind.
+ try {
+ var rawEntry = domain.GetDirectoryEntry();
+ var authType = AuthenticationTypes.Secure;
+ if (config.ForceSSL) {
+ authType |= AuthenticationTypes.SecureSocketsLayer;
+ }
+ if (!config.DisableSigning && !config.ForceSSL) {
+ authType |= AuthenticationTypes.Signing | AuthenticationTypes.Sealing;
+ }
+ rawEntry.AuthenticationType = authType;
+ if (config.Username != null) {
+ rawEntry.Username = config.Username;
+ rawEntry.Password = config.Password;
+ }
- var searchBase = context switch {
- NamingContext.Configuration => $"CN=Configuration,{tempPath}",
- NamingContext.Schema => $"CN=Schema,CN=Configuration,{tempPath}",
- NamingContext.Default => tempPath,
- _ => throw new ArgumentOutOfRangeException()
+ var entry = rawEntry.ToDirectoryObject();
+ if (entry.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) {
+ domainSid = sid.ToUpper();
+ }
+ }
+ catch {
+ //pass
+ }
+
+ info = new DomainInfo(
+ name: name,
+ distinguishedName: distinguishedName,
+ forestName: forestName,
+ domainSid: domainSid,
+ primaryDomainController: primaryDomainController,
+ domainControllers: domainControllers);
+ return true;
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "TryGetDomainInfoViaUncontrolledFallback failed for domain {Name}", domainName);
+ return false;
+ }
+ }
+
+ ///
+ /// Controlled resolver that uses ADSI ()
+ /// via . Sits after the one-shot
+ /// path in and
+ /// , acting as a DC-locator-aware fallback for the
+ /// cases the raw-LDAP tier cannot resolve on its own.
+ ///
+ ///
+ /// ADSI's serverless binding transparently invokes DsGetDcName under the hood, so
+ /// NetBIOS short names and environments where has no direct
+ /// DNS A record still bind here after the preceding one-shot
+ /// tier could not locate a DC. ADSI honors ,
+ /// , ,
+ /// , and . It
+ /// cannot honor (no ADSI API) or
+ /// fine-grained selection (always Negotiate via
+ /// AuthenticationTypes.Secure), which is precisely why this tier is ordered *after*
+ /// the one-shot path: callers that configured those flags will have them respected by the
+ /// raw-LDAP tier and only reach ADSI if that tier failed outright.
+ ///
+ /// Populates , ,
+ /// , , and
+ /// . Does not populate
+ /// or ;
+ /// those are only populated by the pool-based tier, which enumerates via subtree search.
+ ///
+ ///
+ /// ADSI property access is synchronous and blocking; the body is offloaded to a
+ /// thread-pool task so callers remain non-blocking.
+ ///
+ ///
+ private static async Task<(bool Success, DomainInfo DomainInfo)> TryResolveDomainInfoViaDirectoryEntryAsync(
+ string domainName, LdapConfig config, ILogger log) {
+ if (string.IsNullOrWhiteSpace(domainName) || config == null) {
+ return (false, null);
+ }
+
+ return await Task.Run<(bool Success, DomainInfo DomainInfo)>(() => {
+ string name;
+ string distinguishedName;
+ string domainSid = null;
+ string forestName = null;
+ string primaryDomainController = null;
+ IDirectoryObject root;
+ try {
+ root = Helpers.CreateDirectoryEntry($"LDAP://{domainName}", config);
+
+ // Force the bind by reading the DN. When the bind fails (unreachable DC, auth
+ // failure, etc.) TryGetDistinguishedName swallows the underlying COMException
+ // and returns false.
+ if (!root.TryGetDistinguishedName(out var defaultNc) ||
+ string.IsNullOrWhiteSpace(defaultNc)) {
+ return (false, null);
+ }
+
+ name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper();
+ distinguishedName = defaultNc;
+
+ if (root.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) {
+ domainSid = sid.ToUpper();
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "DirectoryEntry tier: base bind failed for {Domain}", domainName);
+ return (false, null);
+ }
+
+ // RootDSE on the same bound server yields the forest root NC without a separate GC bind.
+ try {
+ var rootDse = Helpers.CreateDirectoryEntry($"LDAP://{domainName}/RootDSE", config);
+ if (rootDse.TryGetProperty(LDAPProperties.RootDomainNamingContext, out var rootNc) &&
+ !string.IsNullOrEmpty(rootNc)) {
+ forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper();
+ }
+ }
+ catch (Exception ex) {
+ log?.LogDebug(ex,
+ "DirectoryEntry tier: RootDSE read failed for {Domain}", domainName);
+ }
+
+ // fSMORoleOwner on the domain NC is the PDC NTDS Settings DN; its parent server
+ // object carries dNSHostName. Mirrors the extraction logic in the other tiers.
+ try {
+ if (root.TryGetProperty(LDAPProperties.FSMORoleOwner, out var fsmoOwner) &&
+ TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) {
+ var server = Helpers.CreateDirectoryEntry($"LDAP://{serverDn}", config);
+ if (server.TryGetProperty(LDAPProperties.DNSHostName, out var pdc) &&
+ !string.IsNullOrEmpty(pdc)) {
+ primaryDomainController = pdc;
+ }
+ }
+ }
+ catch (Exception ex) {
+ log?.LogDebug(ex,
+ "DirectoryEntry tier: PDC lookup failed for {Domain}", domainName);
+ }
+
+ return (true, new DomainInfo(
+ name: name,
+ distinguishedName: distinguishedName,
+ forestName: forestName,
+ domainSid: domainSid,
+ primaryDomainController: primaryDomainController));
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ /// Constructs and binds a one-shot
+ /// to honoring every option on (port,
+ /// SSL, signing, auth type, credentials, cert verification). This is the same configuration
+ /// shape used by LdapConnectionPool.CreateBaseConnection, duplicated here because
+ /// this code runs *outside* any pool and must not take a dependency on pool internals.
+ ///
+ ///
+ /// Tries SSL first then plain LDAP, respecting : when
+ /// ForceSSL is set, only the SSL attempt is made. Returns null if neither transport
+ /// binds. The returned connection is owned by the caller and must be disposed.
+ ///
+ private static LdapConnection TryBindOneShotLdapConnection(
+ string target, LdapConfig config, ILogger log) {
+ var transports = config.ForceSSL ? new[] { true } : new[] { true, false };
+ foreach (var ssl in transports) {
+ LdapConnection connection = null;
+ try {
+ var port = config.GetPort(ssl);
+ var identifier = new LdapDirectoryIdentifier(
+ target, port, false, false);
+ connection = new LdapConnection(identifier) {
+ Timeout = TimeSpan.FromSeconds(30),
};
+ connection.SessionOptions.ProtocolVersion = 3;
+ connection.SessionOptions.ReferralChasing =
+ ReferralChasingOptions.None;
+ if (ssl) connection.SessionOptions.SecureSocketLayer = true;
+
+ if (config.DisableSigning || ssl) {
+ connection.SessionOptions.Signing = false;
+ connection.SessionOptions.Sealing = false;
+ } else {
+ connection.SessionOptions.Signing = true;
+ connection.SessionOptions.Sealing = true;
+ }
- return (true, searchBase);
+ if (config.DisableCertVerification)
+ connection.SessionOptions.VerifyServerCertificate = (_, __) => true;
+
+ if (config.Username != null) {
+ connection.Credential = new NetworkCredential(config.Username, config.Password);
+ }
+
+ connection.AuthType = config.AuthType;
+ connection.Bind();
+ return connection;
+ }
+ catch (Exception e) {
+ log?.LogDebug(e,
+ "TryBindOneShotLdapConnection: bind failed for {Target} ssl={Ssl}", target, ssl);
+ connection?.Dispose();
}
}
+ return null;
+ }
- return (false, default);
+ ///
+ /// Controlled resolver used when no is available to
+ /// route queries through. Binds a one-shot
+ /// directly to (treating it as a DNS domain name that resolves
+ /// to a DC via standard round-robin A/SRV records) and populates a
+ /// by issuing the same rootDSE + Base + Subtree queries as
+ /// , just over a private connection.
+ ///
+ ///
+ /// Intended as the intermediate step in between the
+ /// pool-based controlled path and the uncontrolled Domain.GetDomain fallback, so that
+ /// callers inside (which must pass pool = null to
+ /// avoid reentering connection acquisition) still have a controlled resolution option when
+ /// is off. The connection is torn
+ /// down before returning; no state is added to any pool.
+ ///
+ private static async Task<(bool Success, DomainInfo DomainInfo)> TryResolveDomainInfoViaDirectLdapAsync(
+ string domainName, LdapConfig config, ILogger log) {
+ if (string.IsNullOrWhiteSpace(domainName) || config == null) {
+ return (false, null);
+ }
+
+ var connection = TryBindOneShotLdapConnection(domainName, config, log);
+ if (connection == null) {
+ return (false, null);
+ }
+
+ // Offload the synchronous SendRequest calls so we don't block the calling thread when
+ // called from an async code path (e.g. LdapConnectionPool.CreateLdapConnection).
+ return await Task.Run(() => {
+ try {
+ return ResolveDomainInfoFromConnection(connection, domainName, log);
+ }
+ finally {
+ connection.Dispose();
+ }
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ /// Synchronous body of . Uses
+ /// SendRequest directly rather than the pool's Query because no pool is
+ /// available on this path. Follows the same attribute shape as
+ /// : rootDSE → domain NC base →
+ /// PDC server DN → DC subtree enumeration, each wrapped independently so a partial
+ /// failure still returns a usable .
+ ///
+ private static (bool Success, DomainInfo DomainInfo) ResolveDomainInfoFromConnection(
+ LdapConnection connection, string domainName, ILogger log) {
+ // 1. RootDSE - the authoritative source for the default/config/root NCs of the DC we bound to.
+ string defaultNc = null, configNc = null, rootNc = null;
+ try {
+ var rootReq = new SearchRequest(
+ "", "(objectClass=*)", SearchScope.Base,
+ new[] {
+ LDAPProperties.DefaultNamingContext,
+ LDAPProperties.ConfigurationNamingContext,
+ LDAPProperties.RootDomainNamingContext,
+ });
+ var rootResp = (SearchResponse)connection.SendRequest(rootReq);
+ if (rootResp?.Entries != null && rootResp.Entries.Count > 0) {
+ var entry = new SearchResultEntryWrapper(rootResp.Entries[0]);
+ entry.TryGetProperty(LDAPProperties.DefaultNamingContext, out defaultNc);
+ entry.TryGetProperty(LDAPProperties.ConfigurationNamingContext, out configNc);
+ entry.TryGetProperty(LDAPProperties.RootDomainNamingContext, out rootNc);
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "Direct LDAP rootDSE read failed for {Domain}", domainName);
+ return (false, null);
+ }
+
+ if (string.IsNullOrWhiteSpace(defaultNc)) {
+ return (false, null);
+ }
+
+ var name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper();
+ string domainSid = null;
+ string forestName = null;
+ string primaryDomainController = null;
+ IReadOnlyList domainControllers = null;
+ if (!string.IsNullOrWhiteSpace(rootNc)) {
+ forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper();
+ }
+
+ // 2. Domain NC base search - objectSid + fsmoRoleOwner in one round-trip.
+ string fsmoOwner = null;
+ try {
+ var domReq = new SearchRequest(
+ defaultNc, "(objectClass=*)", SearchScope.Base,
+ new[] { LDAPProperties.ObjectSID, LDAPProperties.FSMORoleOwner });
+ var domResp = (SearchResponse)connection.SendRequest(domReq);
+ if (domResp?.Entries != null && domResp.Entries.Count > 0) {
+ var entry = new SearchResultEntryWrapper(domResp.Entries[0]);
+ if (entry.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) {
+ domainSid = sid.ToUpper();
+ }
+ entry.TryGetProperty(LDAPProperties.FSMORoleOwner, out fsmoOwner);
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "Direct LDAP domain NC read failed for {Domain}", domainName);
+ }
+
+ // 3. PDC server dNSHostName via the fsmoRoleOwner DN parent.
+ if (TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) {
+ try {
+ var pdcReq = new SearchRequest(
+ serverDn, "(objectClass=*)", SearchScope.Base,
+ new[] { LDAPProperties.DNSHostName });
+ var pdcResp = (SearchResponse)connection.SendRequest(pdcReq);
+ if (pdcResp?.Entries != null && pdcResp.Entries.Count > 0) {
+ var entry = new SearchResultEntryWrapper(pdcResp.Entries[0]);
+ if (entry.TryGetProperty(LDAPProperties.DNSHostName, out var pdcName)) {
+ primaryDomainController = pdcName;
+ }
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "Direct LDAP PDC lookup failed for {Domain}", domainName);
+ }
+ }
+
+ // 4. Domain controller enumeration - same filter as CommonFilters.DomainControllers.
+ try {
+ var dcs = new List();
+ var dcReq = new SearchRequest(
+ defaultNc, CommonFilters.DomainControllers, SearchScope.Subtree,
+ new[] { LDAPProperties.DNSHostName });
+ var dcResp = (SearchResponse)connection.SendRequest(dcReq);
+ if (dcResp?.Entries != null) {
+ foreach (SearchResultEntry e in dcResp.Entries) {
+ var wrap = new SearchResultEntryWrapper(e);
+ if (wrap.TryGetProperty(LDAPProperties.DNSHostName, out var dcName) &&
+ !string.IsNullOrEmpty(dcName)) {
+ dcs.Add(dcName);
+ }
+ }
+ }
+ domainControllers = dcs;
+ if (string.IsNullOrEmpty(primaryDomainController) && dcs.Count > 0) {
+ primaryDomainController = dcs[0];
+ }
+ }
+ catch (Exception e) {
+ log?.LogDebug(e, "Direct LDAP DC enumeration failed for {Domain}", domainName);
+ }
+
+ return (true, new DomainInfo(
+ name: name,
+ distinguishedName: defaultNc,
+ forestName: forestName,
+ domainSid: domainSid,
+ primaryDomainController: primaryDomainController,
+ domainControllers: domainControllers));
}
public void ResetUtils() {
_unresolvablePrincipals = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase);
_domainCache = new ConcurrentDictionary();
+ _domainInfoCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
_domainControllers = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase);
+ lock (_uncontrolledGetDomainHintLock) {
+ _uncontrolledGetDomainHint = null;
+ }
_connectionPool?.Dispose();
_connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner);
-
+
// Metrics
LdapMetrics.ResetInFlight();
}
diff --git a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs
index 28a6996f6..2f1212ca4 100644
--- a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs
+++ b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs
@@ -66,13 +66,14 @@ public async Task ReadGPOLocalGroups(string gpLink, string
string domain;
//If our dn is null, use our default domain
if (string.IsNullOrEmpty(distinguishedName)) {
- if (!_utils.GetDomain(out var domainResult)) {
+ var (ok, info) = await _utils.GetDomainInfoAsync();
+ if (!ok || string.IsNullOrEmpty(info?.Name)) {
return ret;
}
- domain = domainResult.Name;
+ domain = info.Name;
} else {
- domain = Helpers.DistinguishedNameToDomain(distinguishedName);
+ domain = Helpers.DistinguishedNameToDomain(distinguishedName);
}
// First lets check if this OU actually has computers that it contains. If not, then we'll ignore it.
diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs
index 1e5f1194d..9946bd0f2 100644
--- a/test/unit/Facades/MockLdapUtils.cs
+++ b/test/unit/Facades/MockLdapUtils.cs
@@ -703,6 +703,14 @@ public bool GetDomain(out Domain domain) {
return false;
}
+ public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) {
+ return Task.FromResult<(bool, DomainInfo)>((false, null));
+ }
+
+ public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync() {
+ return Task.FromResult<(bool, DomainInfo)>((false, null));
+ }
+
public async Task<(bool Success, TypedPrincipal Principal)> ResolveAccountName(string name, string domain) {
var res = name.ToUpper() switch {
"ADMINISTRATOR" => new TypedPrincipal(
diff --git a/test/unit/GPOLocalGroupProcessorTest.cs b/test/unit/GPOLocalGroupProcessorTest.cs
index 80f3a59a7..dfdb5ae5e 100644
--- a/test/unit/GPOLocalGroupProcessorTest.cs
+++ b/test/unit/GPOLocalGroupProcessorTest.cs
@@ -323,8 +323,8 @@ public async Task GPOLocalGroupProcessor_ReadGPOLocalGroups() {
.Returns(mockComputerResults.ToAsyncEnumerable)
.Returns(mockGCPFileSysPathResults.ToAsyncEnumerable)
.Returns(Array.Empty>().ToAsyncEnumerable);
- var domain = MockableDomain.Construct("TESTLAB.LOCAL");
- mockLDAPUtils.Setup(x => x.GetDomain(out domain)).Returns(true);
+ var domainInfo = new DomainInfo(name: "TESTLAB.LOCAL");
+ mockLDAPUtils.Setup(x => x.GetDomainInfoAsync()).ReturnsAsync((true, domainInfo));
var processor = new GPOLocalGroupProcessor(mockLDAPUtils.Object);
diff --git a/test/unit/LDAPUtilsTest.cs b/test/unit/LDAPUtilsTest.cs
index 149f52449..6f9966f15 100644
--- a/test/unit/LDAPUtilsTest.cs
+++ b/test/unit/LDAPUtilsTest.cs
@@ -647,5 +647,153 @@ public async Task EnterpriseDomainControllersGroup_CorrectValues() {
Assert.Equal("TESTLAB.LOCAL-S-1-5-9", entDCGroup.ObjectIdentifier);
Assert.Equal(3, entDCGroup.Members.Length);
}
+
+ // ---------------------------------------------------------------------------
+ // AllowFallbackToUncontrolledLdap gate and DomainInfo resolution
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void LdapConfig_AllowFallbackToUncontrolledLdap_DefaultsToFalse() {
+ var config = new LdapConfig();
+ Assert.False(config.AllowFallbackToUncontrolledLdap);
+ }
+
+ [Fact]
+ public void LdapConfig_ToString_IncludesAllowFallbackFlag() {
+ var offConfig = new LdapConfig();
+ Assert.Contains("AllowFallbackToUncontrolledLdap: False", offConfig.ToString());
+
+ var onConfig = new LdapConfig { AllowFallbackToUncontrolledLdap = true };
+ Assert.Contains("AllowFallbackToUncontrolledLdap: True", onConfig.ToString());
+ }
+
+ [Fact]
+ public void LdapConfig_CurrentUserDomain_DefaultsToNull() {
+ var config = new LdapConfig();
+ Assert.Null(config.CurrentUserDomain);
+ }
+
+ [Fact]
+ public void LdapConfig_ToString_IncludesCurrentUserDomain_WhenSet() {
+ var unset = new LdapConfig();
+ Assert.DoesNotContain("CurrentUserDomain:", unset.ToString());
+
+ var set = new LdapConfig { CurrentUserDomain = "CONTOSO.LOCAL" };
+ Assert.Contains("CurrentUserDomain: CONTOSO.LOCAL", set.ToString());
+ }
+
+ [Fact]
+ public async Task GetDomainInfoAsync_ControlledPathFails_FallbackDisabled_ReturnsFailure() {
+ var utils = new LdapUtils();
+ utils.SetLdapConfig(new LdapConfig {
+ Server = "unreachable.invalid.test",
+ AllowFallbackToUncontrolledLdap = false
+ });
+
+ var (success, info) = await utils.GetDomainInfoAsync("unreachable.invalid.test");
+ Assert.False(success);
+ Assert.Null(info);
+ }
+
+ [Fact]
+ public void GetDomain_OutDomain_ReturnsFalse_WhenFallbackDisabled() {
+ var utils = new LdapUtils();
+ utils.SetLdapConfig(new LdapConfig { AllowFallbackToUncontrolledLdap = false });
+
+ Assert.False(utils.GetDomain(out var currentDomain));
+ Assert.Null(currentDomain);
+
+ Assert.False(utils.GetDomain("unreachable.invalid.test", out var namedDomain));
+ Assert.Null(namedDomain);
+
+ Assert.False(LdapUtils.GetDomain("unreachable.invalid.test",
+ new LdapConfig { AllowFallbackToUncontrolledLdap = false }, out var staticDomain));
+ Assert.Null(staticDomain);
+ }
+
+ // ---------------------------------------------------------------------------
+ // TryStripNtdsSettingsPrefix
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void TryStripNtdsSettingsPrefix_StandardDn_StripsPrefixAndReturnsServerDn() {
+ const string input =
+ "CN=NTDS Settings,CN=DC01,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=contoso,DC=local";
+ var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn);
+ Assert.True(ok);
+ Assert.Equal(
+ "CN=DC01,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=contoso,DC=local",
+ serverDn);
+ }
+
+ [Fact]
+ public void TryStripNtdsSettingsPrefix_LowercasePrefix_StripsCaseInsensitively() {
+ const string input = "cn=ntds settings,CN=DC01,CN=Servers,DC=contoso,DC=local";
+ var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn);
+ Assert.True(ok);
+ Assert.Equal("CN=DC01,CN=Servers,DC=contoso,DC=local", serverDn);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void TryStripNtdsSettingsPrefix_NullOrEmptyInput_ReturnsFalse(string input) {
+ var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn);
+ Assert.False(ok);
+ Assert.Null(serverDn);
+ }
+
+ [Fact]
+ public void TryStripNtdsSettingsPrefix_MissingPrefix_ReturnsFalse() {
+ const string input = "CN=DC01,CN=Servers,DC=contoso,DC=local";
+ var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn);
+ Assert.False(ok);
+ Assert.Null(serverDn);
+ }
+
+ [Fact]
+ public void TryStripNtdsSettingsPrefix_PrefixOnly_ReturnsFalse() {
+ const string input = "CN=NTDS Settings,";
+ var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn);
+ Assert.False(ok);
+ Assert.Equal(string.Empty, serverDn);
+ }
+
+ // ---------------------------------------------------------------------------
+ // ResolveEffectiveDomainHint
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void ResolveEffectiveDomainHint_ExplicitDomain_WinsOverCurrentUserDomain() {
+ var utils = new LdapUtils();
+ utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = "FALLBACK.LOCAL" });
+
+ Assert.Equal("EXPLICIT.LOCAL", utils.ResolveEffectiveDomainHint("EXPLICIT.LOCAL"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ResolveEffectiveDomainHint_NullOrWhitespaceInput_FallsBackToCurrentUserDomain(string input) {
+ var utils = new LdapUtils();
+ utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = "CONTOSO.LOCAL" });
+
+ Assert.Equal("CONTOSO.LOCAL", utils.ResolveEffectiveDomainHint(input));
+ }
+
+ [Fact]
+ public void ResolveEffectiveDomainHint_WhitespaceCurrentUserDomain_FallsThroughToEnvironment() {
+ var utils = new LdapUtils();
+ utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = " " });
+
+ // Cannot assert a literal without pinning Environment.UserDomainName; asserting
+ // that the whitespace CurrentUserDomain was skipped and something else was chosen
+ // is sufficient to cover the branch.
+ var result = utils.ResolveEffectiveDomainHint(null);
+ Assert.False(string.IsNullOrWhiteSpace(result));
+ Assert.NotEqual(" ", result);
+ }
+
}
}
\ No newline at end of file