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)); } }