using System.Text;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.Tools.LdapProbe;
/// One directory entry, keeping the raw attributes so binary values stay readable.
public sealed record RawEntry(string Dn, Dictionary Attributes)
{
public string? First(string name) =>
Attributes.TryGetValue(name, out var a) ? a.StringValueArray.FirstOrDefault() : null;
public string[] All(string name) =>
Attributes.TryGetValue(name, out var a) ? a.StringValueArray : [];
public byte[]? Bytes(string name) =>
Attributes.TryGetValue(name, out var a) ? a.ByteValue : null;
}
/// Outcome of one search, including how many continuation referrals it hit.
public sealed record SearchOutcome(List Entries, int Referrals, string? Error);
///
/// Direct LDAP queries that the shared library does not make. The library answers
/// "may this person log in"; this answers "who is this person and what are they in",
/// which is what picking authorization groups requires.
///
public sealed class RawDirectory : IDisposable
{
/// AD's LDAP_MATCHING_RULE_IN_CHAIN — walks nested group membership server-side.
private const string InChain = "1.2.840.113556.1.4.1941";
private readonly LdapConnection _conn;
private readonly ProbeSettings _settings;
private RawDirectory(LdapConnection conn, ProbeSettings settings)
{
_conn = conn;
_settings = settings;
}
public static RawDirectory Connect(ProbeSettings settings, string host, int port,
RemoteCertificateValidationCallbackHolder certs, string bindDn, string bindPassword)
{
var options = new LdapConnectionOptions()
.ConfigureRemoteCertificateValidationCallback(certs.Callback);
var conn = new LdapConnection(options) { ConnectionTimeout = settings.TimeoutMs };
if (settings.Transport == Auth.Abstractions.Ldap.LdapTransport.Ldaps)
conn.SecureSocketLayer = true;
conn.Connect(host, port);
if (settings.Transport == Auth.Abstractions.Ldap.LdapTransport.StartTls)
{
conn.StartTls();
if (!conn.Tls) throw new InvalidOperationException("StartTLS did not produce an encrypted session.");
}
conn.Bind(bindDn, bindPassword);
return new RawDirectory(conn, settings);
}
/// True once the session is encrypted (either transport).
public bool IsEncrypted => _conn.Tls || _conn.SecureSocketLayer;
// ------------------------------------------------------------------------
// search
// ------------------------------------------------------------------------
///
/// Runs a subtree search, tolerating the continuation referrals that AD returns for
/// any search based at a domain root. Referrals are counted rather than thrown,
/// because whether they occur is itself a finding — the shared library's search does
/// not catch , so a referral there surfaces as a
/// system-side failure.
///
public SearchOutcome Search(string filter, string[] attributes, string? baseDn = null)
{
var entries = new List();
var referrals = 0;
try
{
var results = _conn.Search(
baseDn ?? _settings.SearchBase,
LdapConnection.ScopeSub,
filter,
attributes.Length == 0 ? null : attributes,
typesOnly: false);
while (true)
{
try
{
if (!results.HasMore()) break;
var entry = results.Next();
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (LdapAttribute attr in entry.GetAttributeSet())
map[attr.Name] = attr;
entries.Add(new RawEntry(entry.Dn, map));
}
catch (LdapReferralException)
{
// A pointer to another naming context, not an error. Keep reading.
referrals++;
}
catch (LdapException ex) when (ex.ResultCode == LdapException.Referral)
{
referrals++;
}
}
}
catch (Exception ex)
{
return new SearchOutcome(entries, referrals, Describe(ex));
}
return new SearchOutcome(entries, referrals, null);
}
/// Finds the user by the configured login attribute — the same lookup the library performs.
public SearchOutcome FindUser(string username) => Search(
$"({_settings.UserNameAttribute}={Escape(username)})",
[
"distinguishedName", "sAMAccountName", "userPrincipalName", "name", "displayName", "cn",
"mail", "telephoneNumber", "mobile", "department", "title",
"memberOf", "primaryGroupID", "objectSid", "userAccountControl",
"whenCreated", "lastLogonTimestamp", "pwdLastSet", "objectClass",
_settings.GroupAttribute, _settings.DisplayNameAttribute
]);
///
/// Every group the user is in including nested ones, resolved server-side.
/// This is the set that matters when choosing an authorization group: a user is
/// commonly a member of a role group only by way of a team group, and memberOf
/// shows only the direct edge.
///
public SearchOutcome TransitiveGroups(string userDn) => Search(
$"(&(objectClass=group)(member:{InChain}:={Escape(userDn)}))",
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
///
/// Resolves the primary group, which AD deliberately omits from memberOf.
/// Its SID is the user's own SID with the last sub-authority replaced by
/// primaryGroupID, so it is derived rather than looked up by name.
///
public RawEntry? PrimaryGroup(RawEntry user)
{
var sid = user.Bytes("objectSid");
var idText = user.First("primaryGroupID");
if (sid is null || sid.Length < 8 || !uint.TryParse(idText, out var rid)) return null;
var groupSid = (byte[])sid.Clone();
BitConverter.GetBytes(rid).CopyTo(groupSid, groupSid.Length - 4);
var outcome = Search($"(objectSid={EscapeBinary(groupSid)})",
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
return outcome.Entries.FirstOrDefault();
}
///
/// Enumerates every group whose cn matches a wildcard pattern — the directory-wide
/// view, as opposed to the one account's membership every other query here reports.
///
///
/// Deliberately based at the domain root by default: groups here are spread across at
/// least three containers (OU=Universal,OU=Groups,OU=Warsaw, OU=Groups,OU=Warsaw
/// and OU=Groups at the root), so anything narrower silently under-reports.
///
public SearchOutcome ListGroups(string cnPattern, string baseDn) => Search(
$"(&(objectClass=group)(cn={EscapePattern(cnPattern)}))",
["distinguishedName", "cn", "name", "description", "groupType", "member"],
baseDn);
///
/// Counts a group's members, reporting whether AD ranged the attribute.
///
///
/// AD caps a multi-valued attribute at 1500 values per response and renames the attribute
/// to member;range=0-1499 when it does. Reporting a flat count off that would
/// understate a large group, so the ranged case is surfaced rather than hidden — the count
/// is only ever used here to tell a populated group from an empty one.
///
public static (int Count, bool Ranged) MemberCount(RawEntry group)
{
var attribute = group.Attributes
.FirstOrDefault(kv => kv.Key.StartsWith("member", StringComparison.OrdinalIgnoreCase));
if (attribute.Key is null) return (0, false);
return (attribute.Value.StringValueArray.Length,
attribute.Key.Contains("range=", StringComparison.OrdinalIgnoreCase));
}
/// Derives the domain root (the DC= components) from any DN.
public static string DomainRoot(string dn)
{
var parts = dn.Split(',')
.Select(p => p.Trim())
.SkipWhile(p => !p.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
.ToArray();
return parts.Length > 0 ? string.Join(",", parts) : dn;
}
/// Resolves group DNs to entries so distribution vs security groups can be told apart.
public Dictionary DescribeGroups(IEnumerable dns)
{
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var dn in dns.Distinct(StringComparer.OrdinalIgnoreCase))
{
var outcome = Search($"(distinguishedName={Escape(dn)})",
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
if (outcome.Entries.Count > 0) map[dn] = outcome.Entries[0];
}
return map;
}
// ------------------------------------------------------------------------
// formatting helpers
// ------------------------------------------------------------------------
/// RFC 4515 filter-value escaping.
public static string Escape(string value)
{
var sb = new StringBuilder(value.Length);
foreach (var c in value)
{
sb.Append(c switch
{
'\\' => "\\5c",
'*' => "\\2a",
'(' => "\\28",
')' => "\\29",
'\0' => "\\00",
_ => c.ToString()
});
}
return sb.ToString();
}
///
/// Escapes a filter value while preserving * as a wildcard. Used only for
/// operator-supplied search patterns; ordinary values must go through ,
/// where a literal * in a name would otherwise silently become a wildcard.
///
public static string EscapePattern(string pattern)
{
var sb = new StringBuilder(pattern.Length);
foreach (var c in pattern)
{
sb.Append(c switch
{
'\\' => "\\5c",
'(' => "\\28",
')' => "\\29",
'\0' => "\\00",
_ => c.ToString()
});
}
return sb.ToString();
}
/// Binary filter-value form: every byte as \XX.
public static string EscapeBinary(byte[] bytes)
{
var sb = new StringBuilder(bytes.Length * 3);
foreach (var b in bytes) sb.Append('\\').Append(b.ToString("x2"));
return sb.ToString();
}
/// Renders a binary SID in the familiar S-1-5-21-… form.
public static string FormatSid(byte[]? sid)
{
if (sid is null || sid.Length < 8) return "(none)";
var revision = sid[0];
var subAuthorityCount = sid[1];
ulong authority = 0;
for (var i = 2; i < 8; i++) authority = (authority << 8) | sid[i];
var sb = new StringBuilder($"S-{revision}-{authority}");
for (var i = 0; i < subAuthorityCount && 8 + i * 4 + 4 <= sid.Length; i++)
sb.Append('-').Append(BitConverter.ToUInt32(sid, 8 + i * 4));
return sb.ToString();
}
/// Decodes the groupType bitmask into scope + security/distribution.
public static string FormatGroupType(string? raw)
{
if (!int.TryParse(raw, out var value)) return "unknown";
var scope = (value & 0x08) != 0 ? "universal"
: (value & 0x04) != 0 ? "domain-local"
: (value & 0x02) != 0 ? "global"
: "builtin";
var kind = (value & unchecked((int)0x80000000)) != 0 ? "security" : "distribution";
return $"{scope} {kind}";
}
/// Decodes the userAccountControl flags that explain a failed or fragile login.
public static string FormatUac(string? raw)
{
if (!int.TryParse(raw, out var value)) return "unknown";
var flags = new List();
if ((value & 0x0002) != 0) flags.Add("*** DISABLED ***");
if ((value & 0x0010) != 0) flags.Add("*** LOCKED OUT ***");
if ((value & 0x0020) != 0) flags.Add("password-not-required");
if ((value & 0x0200) != 0) flags.Add("normal-account");
if ((value & 0x10000) != 0) flags.Add("password-never-expires");
if ((value & 0x800000) != 0) flags.Add("*** PASSWORD EXPIRED ***");
if ((value & 0x400000) != 0) flags.Add("no-preauth-required");
return flags.Count == 0 ? $"0x{value:x}" : $"0x{value:x} ({string.Join(", ", flags)})";
}
/// Short name from a DN: the value of its first RDN.
public static string ShortName(string dn)
{
var first = dn.Split(',')[0];
var eq = first.IndexOf('=');
return eq >= 0 ? first[(eq + 1)..] : first;
}
public static string Describe(Exception ex) => ex switch
{
LdapException l => $"LDAP {l.ResultCode} {l.ResultCode switch
{
LdapException.InvalidCredentials => "(invalid credentials)",
LdapException.NoSuchObject => "(no such object — check the search base)",
LdapException.InsufficientAccessRights => "(insufficient access rights)",
LdapException.OperationsError => "(operations error — often 'bind first' or a referral issue)",
LdapException.ConnectError => "(connect error)",
LdapException.SizeLimitExceeded => "(size limit exceeded)",
_ => ""
}} — {l.Message}{(string.IsNullOrWhiteSpace(l.LdapErrorMessage) ? "" : $" | {l.LdapErrorMessage}")}",
_ => $"{ex.GetType().Name}: {ex.Message}"
};
///
/// Bounded teardown. Novell can block indefinitely closing a TLS-upgraded connection, so
/// the disconnect runs on a worker and is abandoned after a short grace period rather than
/// hanging a probe whose useful output has already been printed.
///
public void Dispose()
{
var closing = Task.Run(() =>
{
try
{
if (_conn.Connected) _conn.Disconnect();
_conn.Dispose();
}
catch (Exception)
{
// Nothing useful to do while tearing a diagnostic connection down.
}
});
closing.Wait(TimeSpan.FromSeconds(3));
}
}