using System.Diagnostics; using Novell.Directory.Ldap; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap; using ZB.MOM.WW.Tools.LdapProbe; // --------------------------------------------------------------------------- // arguments // --------------------------------------------------------------------------- ProbeSettings settings; try { settings = ProbeSettings.Parse(args, out var showHelp); if (showHelp) { Console.WriteLine(ProbeSettings.Help); return 0; } } catch (Exception ex) { Ui.Bad(ex.Message); Console.WriteLine(); Console.WriteLine("Run with --help for usage."); return 2; } Console.WriteLine(); Ui.Section("effective settings"); Ui.Field("primary DC", $"{settings.Server}:{settings.Port}"); Ui.Field("fallback DCs", settings.Fallbacks.Count == 0 ? "(none)" : string.Join(", ", settings.Fallbacks)); Ui.Field("transport", settings.Transport.ToString() + (settings.Insecure ? " [--insecure: certificate NOT validated]" : "")); Ui.Field("search base", settings.SearchBase); Ui.Field("login attribute", settings.UserNameAttribute); Ui.Field("group attribute", settings.GroupAttribute); Ui.Field("display attribute", settings.DisplayNameAttribute); Ui.Field("bind identity", settings.SelfBind ? "(self-bind: the user under test)" : settings.ServiceAccount); var certs = new RemoteCertificateValidationCallbackHolder(settings.Insecure, settings.ShowCertificate); // --------------------------------------------------------------------------- // optional transport sweep — answers "what actually connects from here" // --------------------------------------------------------------------------- if (settings.SweepTransports) { Ui.Section("transport sweep"); foreach (var host in new[] { settings.Server }.Concat(settings.Fallbacks)) { foreach (var (transport, port) in new (LdapTransport, int)[] { (LdapTransport.Ldaps, 636), (LdapTransport.StartTls, 389), (LdapTransport.None, 389) }) { var sw = Stopwatch.StartNew(); // Each attempt runs on its own worker under a hard wall-clock budget. Novell's // teardown of a StartTLS-upgraded connection can block indefinitely, which would // otherwise strand the sweep on its second probe and report nothing at all. var attempt = Task.Run(() => { var options = new LdapConnectionOptions().ConfigureRemoteCertificateValidationCallback(certs.Callback); var conn = new LdapConnection(options) { ConnectionTimeout = settings.TimeoutMs }; try { if (transport == LdapTransport.Ldaps) conn.SecureSocketLayer = true; conn.Connect(host, port); if (transport == LdapTransport.StartTls) conn.StartTls(); return conn.Tls || conn.SecureSocketLayer ? " (encrypted)" : " (CLEARTEXT)"; } finally { // Abandoned deliberately if it blocks: the probe is about to exit anyway. _ = Task.Run(() => { try { conn.Dispose(); } catch { /* teardown only */ } }); } }); var label = $"{host,-34} {transport,-9} :{port,-5}"; if (!attempt.Wait(settings.TimeoutMs)) Ui.Bad($"{label} timed out after {settings.TimeoutMs} ms"); else if (attempt.IsCompletedSuccessfully) Ui.Ok($"{label} connected in {sw.ElapsedMilliseconds} ms{attempt.Result}"); else Ui.Bad($"{label} {RawDirectory.Describe(attempt.Exception?.GetBaseException() ?? new Exception("unknown"))}"); } } // A sweep with no username named is a connectivity check on its own — don't then // demand credentials for an auth test that was never asked for. if (string.IsNullOrWhiteSpace(settings.Username)) { Console.WriteLine(); Ui.Info("Sweep complete. Pass a username (or -u ) to also run the auth + group probe."); return 0; } } // --------------------------------------------------------------------------- // credentials // --------------------------------------------------------------------------- Ui.Section("credentials"); var username = Ui.Prompt($"username ({settings.UserNameAttribute})", settings.Username); if (string.IsNullOrWhiteSpace(username)) { Ui.Bad("No username supplied."); return 2; } var password = Ui.PromptSecret($"password for {username}"); if (password.Length == 0) { Ui.Bad("No password supplied."); return 2; } // In self-bind mode the user's own credentials stand in for the service account. AD accepts a // UPN for a simple bind, and an authenticated user can read the directory — so the full // library path still runs, just without needing the svc_igntnapp secret. string bindIdentity; string bindPassword; if (settings.SelfBind) { bindIdentity = username.Contains('@') ? username : username + settings.UpnSuffix; bindPassword = password; Ui.Info($"self-bind: searching as {bindIdentity}"); } else { bindIdentity = settings.ServiceAccount; bindPassword = settings.ServiceAccountPassword ?? Ui.PromptSecret($"password for service account {settings.ServiceAccount}"); if (bindPassword.Length == 0) { Ui.Bad("No service-account password. Supply one, set $ZB_LDAP_SVC_PASSWORD, or use --self-bind."); return 2; } } settings.ServiceAccount = bindIdentity; settings.ServiceAccountPassword = bindPassword; // --------------------------------------------------------------------------- // PART A — the real shared library // --------------------------------------------------------------------------- Ui.Section("PART A — ZB.MOM.WW.Auth.Ldap (what the apps will do)"); var logger = new ConsoleLogger(settings.Verbose); var result = await RunLibraryAsync(settings, certs, logger, username, password); ReportLibrary(result); // --------------------------------------------------------------------------- // PART B — direct queries the library does not make // --------------------------------------------------------------------------- Ui.Section("PART B — directory detail (group discovery)"); RawEntry? user = null; List directGroups = []; List transitiveGroups = []; RawEntry? primaryGroup = null; try { using var directory = RawDirectory.Connect( settings, settings.Server, settings.Port, certs, bindIdentity, bindPassword); Ui.Ok($"bound as {bindIdentity} ({(directory.IsEncrypted ? "encrypted" : "CLEARTEXT — simple bind sent in the clear")})"); var found = directory.FindUser(username); if (found.Error is not null) Ui.Bad($"user search failed: {found.Error}"); if (found.Referrals > 0) Ui.Info($"({found.Referrals} continuation referral(s) skipped for a search based at " + $"{settings.SearchBase} — handled since ZB.MOM.WW.Auth.Ldap 0.2.1; an app still " + "pinned below that will fail this search with ServiceAccountBindFailed)"); if (found.Entries.Count == 0) { Ui.Bad($"no entry matched ({settings.UserNameAttribute}={username}) under {settings.SearchBase}"); } else if (found.Entries.Count > 1) { Ui.Bad($"{found.Entries.Count} entries matched — the library rejects this as AmbiguousUser:"); foreach (var e in found.Entries) Ui.Info(e.Dn); } else { user = found.Entries[0]; ReportUser(user); directGroups = [.. user.All(settings.GroupAttribute)]; var described = directory.DescribeGroups(directGroups); ReportDirectGroups(directGroups, described); primaryGroup = directory.PrimaryGroup(user); ReportPrimaryGroup(primaryGroup, user); var transitive = directory.TransitiveGroups(user.Dn); if (transitive.Error is not null) Ui.Bad($"transitive group search failed: {transitive.Error}"); transitiveGroups = transitive.Entries; ReportTransitiveGroups(transitiveGroups, directGroups); } // Directory-wide group inventory. Independent of the user above: it answers "what groups // exist to choose from", which no membership dump can, because a membership dump only ever // shows the groups the one test account happens to be in. if (!string.IsNullOrWhiteSpace(settings.ListGroupsPattern)) { var groupBase = settings.GroupBase ?? RawDirectory.DomainRoot(user?.Dn ?? settings.SearchBase); var listed = directory.ListGroups(settings.ListGroupsPattern, groupBase); ReportGroupInventory(settings.ListGroupsPattern, groupBase, listed, user is null ? [] : [.. user.All(settings.GroupAttribute)]); } } catch (Exception ex) { Ui.Bad($"raw directory probe failed: {RawDirectory.Describe(ex)}"); } // --------------------------------------------------------------------------- // If the library failed system-side but the raw probe worked, the search base is the // prime suspect. Retry Part A narrowed to the user's own container and report. // --------------------------------------------------------------------------- if (result.Failure == LdapAuthFailure.ServiceAccountBindFailed && user is not null) { var parent = ParentDn(user.Dn); if (parent is not null && !parent.Equals(settings.SearchBase, StringComparison.OrdinalIgnoreCase)) { Ui.Section("PART A retry — narrowed search base"); Ui.Info($"The library failed system-side while a direct query succeeded, so the search base"); Ui.Info($"is the likeliest cause. Retrying against the user's own container:"); Ui.Info($" {parent}"); var narrowed = new ProbeSettings { Server = settings.Server, Fallbacks = settings.Fallbacks, PortOverride = settings.Port, Transport = settings.Transport, Insecure = settings.Insecure, TimeoutMs = settings.TimeoutMs, SearchBase = parent, UserNameAttribute = settings.UserNameAttribute, DisplayNameAttribute = settings.DisplayNameAttribute, GroupAttribute = settings.GroupAttribute, ServiceAccount = bindIdentity, ServiceAccountPassword = bindPassword, }; var retry = await RunLibraryAsync(narrowed, certs, logger, username, password); ReportLibrary(retry); if (retry.Succeeded) { Ui.Ok($"CONFIRMED: set SearchBase to a container below the domain root. " + "The root-level search is what fails."); settings.SearchBase = parent; result = retry; } } } // --------------------------------------------------------------------------- // assessment + ready-to-paste configuration // --------------------------------------------------------------------------- Assess(result, user, directGroups, transitiveGroups, primaryGroup, settings); EmitConfig(settings, result); Console.Out.Flush(); // Hard exit: an abandoned LDAP teardown worker would otherwise keep the process alive // after every useful line has been printed. Environment.Exit(result.Succeeded ? 0 : 1); return 0; // =========================================================================== // helpers // =========================================================================== static async Task RunLibraryAsync(ProbeSettings settings, RemoteCertificateValidationCallbackHolder certs, ConsoleLogger logger, string username, string password) { var options = settings.ToLdapOptions(certs); var validation = new LdapOptionsValidator().Validate(null, options); if (validation.Failed) { Ui.Bad($"options rejected by the library's own boot validator: {validation.FailureMessage}"); return LdapAuthResult.Fail(LdapAuthFailure.Disabled); } var service = new LdapAuthService(options, logger); var sw = Stopwatch.StartNew(); var result = await service.AuthenticateAsync(username, password, CancellationToken.None); Ui.Info($"completed in {sw.ElapsedMilliseconds} ms"); return result; } static void ReportLibrary(LdapAuthResult result) { if (result.Succeeded) { Ui.Ok("AUTHENTICATED"); Ui.Field("username", result.Username); Ui.Field("display name", result.DisplayName); Ui.Field("groups seen", result.Groups.Count.ToString()); foreach (var g in result.Groups) Ui.Info($" • {g}"); return; } Ui.Bad($"FAILED — {result.Failure}"); Ui.Info(result.Failure switch { LdapAuthFailure.BadCredentials => "The directory answered: the password did not verify (or the username is empty).", LdapAuthFailure.UserNotFound => "The search ran but matched no entry. Suspect the search base or the login attribute.", LdapAuthFailure.AmbiguousUser => "The search matched more than one entry. The filter is not selective enough.", LdapAuthFailure.GroupLookupFailed => "The password VERIFIED, but the user resolved to zero groups. The library refuses to " + "admit a zero-group success, so this is the known NAM blocker, not a credential problem.", LdapAuthFailure.ServiceAccountBindFailed => "System-side: connect/TLS, the service-account bind, or the search itself. The library " + "collapses all three into this one value — Part B below separates them.", LdapAuthFailure.Disabled => "The provider was disabled.", _ => "" }); } static void ReportUser(RawEntry user) { Ui.Ok($"found {user.Dn}"); Ui.Field("sAMAccountName", user.First("sAMAccountName")); Ui.Field("userPrincipalName", user.First("userPrincipalName")); Ui.Field("name", user.First("name")); Ui.Field("displayName", user.First("displayName")); Ui.Field("mail", user.First("mail")); Ui.Field("department", user.First("department")); Ui.Field("title", user.First("title")); Ui.Field("objectSid", RawDirectory.FormatSid(user.Bytes("objectSid"))); Ui.Field("userAccountControl", RawDirectory.FormatUac(user.First("userAccountControl"))); Ui.Field("whenCreated", user.First("whenCreated")); } static void ReportDirectGroups(List dns, Dictionary described) { Console.WriteLine(); Ui.Section($"direct groups — the {dns.Count} the shared library sees (memberOf)"); if (dns.Count == 0) { Ui.Bad("none. The library treats zero groups as GroupLookupFailed, so login is refused " + "even with a correct password."); return; } foreach (var dn in dns.OrderBy(RawDirectory.ShortName, StringComparer.OrdinalIgnoreCase)) { var type = described.TryGetValue(dn, out var e) ? RawDirectory.FormatGroupType(e.First("groupType")) : "?"; var description = described.TryGetValue(dn, out var e2) ? e2.First("description") : null; Ui.Info($" • {RawDirectory.ShortName(dn),-42} [{type}]"); Ui.Info($" {dn}"); if (!string.IsNullOrWhiteSpace(description)) Ui.Info($" \"{description}\""); } } static void ReportPrimaryGroup(RawEntry? group, RawEntry user) { Console.WriteLine(); Ui.Section("primary group — present in AD, ABSENT from memberOf"); if (group is null) { Ui.Info($"could not resolve (primaryGroupID={user.First("primaryGroupID") ?? "?"})"); return; } Ui.Info($" • {RawDirectory.ShortName(group.Dn),-42} [{RawDirectory.FormatGroupType(group.First("groupType"))}]"); Ui.Info($" {group.Dn}"); Ui.Warn("Do NOT use this for authorization: AD omits the primary group from memberOf, so the " + "shared library will never see it."); } static void ReportTransitiveGroups(List groups, List directDns) { Console.WriteLine(); Ui.Section($"effective groups — {groups.Count} including nested (LDAP_MATCHING_RULE_IN_CHAIN)"); if (groups.Count == 0) { Ui.Info("none resolved."); return; } var direct = new HashSet(directDns, StringComparer.OrdinalIgnoreCase); var nestedOnly = 0; foreach (var g in groups.OrderBy(g => RawDirectory.ShortName(g.Dn), StringComparer.OrdinalIgnoreCase)) { var isDirect = direct.Contains(g.Dn); if (!isDirect) nestedOnly++; var marker = isDirect ? "direct " : "NESTED "; Ui.Info($" • [{marker}] {RawDirectory.ShortName(g.Dn),-40} [{RawDirectory.FormatGroupType(g.First("groupType"))}]"); if (!isDirect) Ui.Info($" {g.Dn}"); } if (nestedOnly > 0) { Console.WriteLine(); Ui.Warn($"{nestedOnly} group(s) are reachable only through nesting. The shared library reads " + "memberOf, which holds direct edges ONLY — so choosing one of these as a role group " + "would silently grant nobody anything."); } } /// /// Prints the directory-wide inventory for a group-name pattern, marking which entries the /// probed account already holds so an existing convention can be read off directly. /// static void ReportGroupInventory(string pattern, string baseDn, SearchOutcome listed, List heldByUser) { Console.WriteLine(); Ui.Section($"group inventory — cn={pattern}"); Ui.Info($"searched {baseDn}"); if (listed.Error is not null) { Ui.Bad($"group enumeration failed: {listed.Error}"); return; } if (listed.Referrals > 0) Ui.Info($"({listed.Referrals} continuation referral(s) skipped)"); if (listed.Entries.Count == 0) { Ui.Warn($"no group matched cn={pattern}. Check the pattern and that {baseDn} is broad enough."); return; } var held = new HashSet(heldByUser, StringComparer.OrdinalIgnoreCase); Ui.Ok($"{listed.Entries.Count} group(s) match — 'you' marks the ones the probed account holds"); Console.WriteLine(); foreach (var g in listed.Entries.OrderBy(g => RawDirectory.ShortName(g.Dn), StringComparer.OrdinalIgnoreCase)) { var (count, ranged) = RawDirectory.MemberCount(g); var members = count == 0 ? "EMPTY" : ranged ? $"{count}+" : count.ToString(); Ui.Info($" {(held.Contains(g.Dn) ? "you" : " ")} {RawDirectory.ShortName(g.Dn),-40} " + $"{members,6} members [{RawDirectory.FormatGroupType(g.First("groupType"))}]"); var description = g.First("description"); if (!string.IsNullOrWhiteSpace(description)) Ui.Info($" \"{description}\""); } var empty = listed.Entries.Count(g => RawDirectory.MemberCount(g).Count == 0); if (empty > 0) { Console.WriteLine(); Ui.Warn($"{empty} of these are EMPTY. An empty group is a valid role target only if " + "someone will populate it — mapping a role to one grants nobody anything today."); } Console.WriteLine(); Ui.Info("'N+' means AD ranged the member attribute at its 1500-value cap, so the real count is higher."); } static void Assess(LdapAuthResult result, RawEntry? user, List direct, List transitive, RawEntry? primary, ProbeSettings settings) { Ui.Section("assessment"); if (result.Succeeded) Ui.Ok("Authentication works with these settings. They can go straight into the apps."); else if (result.Failure == LdapAuthFailure.GroupLookupFailed) Ui.Warn("Credentials are correct and the directory is reachable — only the zero-group rule " + "blocks login. Grant this user a group, or map an existing one below."); else if (user is not null) Ui.Warn("The directory is reachable and the user resolves, but the library still refuses. " + "Compare Part A's failure with the detail above."); else Ui.Bad("The probe could not resolve the user. Fix connectivity/base/attribute first."); if (!settings.Insecure && settings.Transport != LdapTransport.None) Ui.Info("TLS was validated against this machine's trust store — the chain is genuinely trusted here."); else if (settings.Insecure) Ui.Warn("--insecure was used: certificate validation was BYPASSED. Before deploying, either " + "install zimmer-rootca into the host trust store or pin the chain via " + "LdapOptions.ServerCertificateValidationCallback."); if (settings.Transport == LdapTransport.None) Ui.Warn("Transport=None sends the simple bind — including the password — in cleartext. " + "The DCs accept LDAPS on 636 and StartTLS on 389; prefer one of those."); // The point of the whole exercise: which groups are actually usable as role groups. var usable = transitive.Count > 0 ? transitive.Where(g => new HashSet(direct, StringComparer.OrdinalIgnoreCase).Contains(g.Dn)).ToList() : []; if (usable.Count > 0) { Console.WriteLine(); Ui.Info("Candidate role groups — direct AND security-enabled, i.e. the ones the shared library"); Ui.Info("will actually see in memberOf:"); foreach (var g in usable.Where(g => RawDirectory.FormatGroupType(g.First("groupType")).Contains("security"))) Ui.Info($" \"{RawDirectory.ShortName(g.Dn)}\""); } if (primary is not null && direct.Count == 0) Ui.Warn($"This user's ONLY group is the primary group ({RawDirectory.ShortName(primary.Dn)}), " + "which memberOf never returns. That is exactly why the login fails."); } static void EmitConfig(ProbeSettings settings, LdapAuthResult result) { Ui.Section("appsettings block for these settings"); var fallbacks = settings.Fallbacks.Count == 0 ? "[]" : "[ " + string.Join(", ", settings.Fallbacks.Select(f => $"\"{f}\"")) + " ]"; Console.WriteLine($$""" "Ldap": { "Enabled": true, "Server": "{{settings.Server}}", "Port": {{settings.Port}}, "FallbackServers": {{fallbacks}}, "Transport": "{{settings.Transport}}", "AllowInsecure": false, "SearchBase": "{{settings.SearchBase}}", "ServiceAccountDn": "svc_igntnapp@nam.zimmer.com", "ServiceAccountPassword": "${secret:LDAP_SVC_PASSWORD}", "UserNameAttribute": "{{settings.UserNameAttribute}}", "DisplayNameAttribute": "{{settings.DisplayNameAttribute}}", "GroupAttribute": "{{settings.GroupAttribute}}", "ConnectionTimeoutMs": {{settings.TimeoutMs}} } """); Console.WriteLine(); Ui.Info("Notes: the service-account password belongs in the Secrets store as a ${secret:} reference,"); Ui.Info("never inline. AllowInsecure is deliberately false here even if the probe used --insecure —"); Ui.Info("trust must be established properly before deployment."); if (!result.Succeeded) Ui.Warn("These settings did NOT produce a successful login in this run."); } static string? ParentDn(string dn) { var comma = dn.IndexOf(','); return comma < 0 || comma + 1 >= dn.Length ? null : dn[(comma + 1)..].Trim(); }