diff --git a/tools/LdapProbe/CertificateInspector.cs b/tools/LdapProbe/CertificateInspector.cs
new file mode 100644
index 0000000..db6cc7a
--- /dev/null
+++ b/tools/LdapProbe/CertificateInspector.cs
@@ -0,0 +1,124 @@
+using System.Net.Security;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace ZB.MOM.WW.Tools.LdapProbe;
+
+///
+/// Wraps the TLS server-certificate validation callback so the probe can *report* the
+/// presented chain rather than silently accept or reject it.
+///
+///
+/// This exists because ldap_setup.md records two distinct TLS hazards that both
+/// present as a generic connect failure: the DC certs chain to the internal
+/// zimmer-rootca (absent from a dev machine's trust store), and they carry
+/// RSA 1024-bit keys, which OpenSSL at its default security level rejects outright.
+/// Printing the subject, key size and the exact turns
+/// "connection refused" into a diagnosis.
+///
+public sealed class RemoteCertificateValidationCallbackHolder
+{
+ private readonly bool _accept;
+ private readonly bool _report;
+ private int _reported;
+
+ public RemoteCertificateValidationCallbackHolder(bool acceptAnything, bool report)
+ {
+ _accept = acceptAnything;
+ _report = report;
+ }
+
+ /// The last chain observed, for the summary at the end of a run.
+ public string? Summary { get; private set; }
+
+ public RemoteCertificateValidationCallback Callback => Validate;
+
+ private bool Validate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
+ {
+ // Report once per process: a failover walk opens several connections and the
+ // chain is the same each time.
+ if (_report && Interlocked.Exchange(ref _reported, 1) == 0)
+ Describe(certificate, chain, errors);
+
+ return _accept || errors == SslPolicyErrors.None;
+ }
+
+ private void Describe(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
+ {
+ var lines = new List();
+
+ if (certificate is null)
+ {
+ lines.Add("server presented NO certificate");
+ }
+ else
+ {
+ using var cert = new X509Certificate2(certificate);
+ lines.Add($"subject {cert.Subject}");
+ lines.Add($"issuer {cert.Issuer}");
+ lines.Add($"valid {cert.NotBefore:yyyy-MM-dd} -> {cert.NotAfter:yyyy-MM-dd}"
+ + (cert.NotAfter < DateTime.Now ? " *** EXPIRED ***" : ""));
+ lines.Add($"sig alg {cert.SignatureAlgorithm.FriendlyName}");
+
+ var (algorithm, size) = KeyInfo(cert);
+ var weak = size is > 0 and < 2048;
+ lines.Add($"key {algorithm} {size}-bit" + (weak
+ ? " *** WEAK — OpenSSL SECLEVEL>=2 (Debian .NET containers) rejects this ***"
+ : ""));
+
+ var san = cert.Extensions
+ .OfType()
+ .SelectMany(e => e.EnumerateDnsNames())
+ .ToArray();
+ if (san.Length > 0)
+ lines.Add($"SAN {string.Join(", ", san)}");
+ }
+
+ lines.Add($"policy {(errors == SslPolicyErrors.None ? "None (chain validated cleanly)" : errors.ToString())}");
+
+ if (chain is not null)
+ {
+ foreach (var element in chain.ChainElements)
+ {
+ var status = element.ChainElementStatus.Length == 0
+ ? "ok"
+ : string.Join("; ", element.ChainElementStatus.Select(s => s.Status.ToString()));
+ lines.Add($" chain {Shorten(element.Certificate.Subject)} [{status}]");
+ }
+ }
+
+ lines.Add(_accept
+ ? "verdict ACCEPTED because --insecure was passed (diagnostic only — do not ship this)"
+ : errors == SslPolicyErrors.None
+ ? "verdict ACCEPTED by the OS trust store"
+ : "verdict REJECTED — rerun with --insecure to see past this, or install zimmer-rootca");
+
+ Summary = string.Join(Environment.NewLine, lines.Select(l => " " + l));
+ Ui.Section("TLS server certificate");
+ Console.WriteLine(Summary);
+ Console.WriteLine();
+ }
+
+ private static (string Algorithm, int Size) KeyInfo(X509Certificate2 cert)
+ {
+ try
+ {
+ using var rsa = cert.GetRSAPublicKey();
+ if (rsa is not null) return ("RSA", rsa.KeySize);
+ using var ecdsa = cert.GetECDsaPublicKey();
+ if (ecdsa is not null) return ("ECDSA", ecdsa.KeySize);
+ }
+ catch (CryptographicException)
+ {
+ // An unreadable key is itself worth reporting rather than crashing the probe.
+ }
+
+ return (cert.PublicKey.Oid.FriendlyName ?? "unknown", 0);
+ }
+
+ private static string Shorten(string dn)
+ {
+ var cn = dn.Split(',').FirstOrDefault(p => p.TrimStart().StartsWith("CN=", StringComparison.OrdinalIgnoreCase));
+ return cn?.Trim() ?? dn;
+ }
+}
diff --git a/tools/LdapProbe/ConsoleLogger.cs b/tools/LdapProbe/ConsoleLogger.cs
new file mode 100644
index 0000000..326adc2
--- /dev/null
+++ b/tools/LdapProbe/ConsoleLogger.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.Tools.LdapProbe;
+
+///
+/// Minimal logger so the shared library's failover diagnostics are visible during a probe.
+/// LdapAuthService logs a Warning per failed endpoint hop and an Information line when
+/// its sticky preference moves — exactly the events a DC-failover test needs to observe.
+///
+public sealed class ConsoleLogger : ILogger
+{
+ private readonly bool _verbose;
+
+ public ConsoleLogger(bool verbose) => _verbose = verbose;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => _verbose || logLevel >= LogLevel.Information;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel)) return;
+
+ var message = formatter(state, exception);
+ if (logLevel >= LogLevel.Warning) Ui.Warn($"[library] {message}");
+ else Ui.Info($"[library] {message}");
+
+ if (exception is not null && _verbose)
+ Ui.Info($" {RawDirectory.Describe(exception)}");
+ }
+}
diff --git a/tools/LdapProbe/LdapProbe.csproj b/tools/LdapProbe/LdapProbe.csproj
new file mode 100644
index 0000000..1ecb147
--- /dev/null
+++ b/tools/LdapProbe/LdapProbe.csproj
@@ -0,0 +1,33 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ ZB.MOM.WW.Tools.LdapProbe
+ ldapprobe
+
+ false
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/LdapProbe/ProbeSettings.cs b/tools/LdapProbe/ProbeSettings.cs
new file mode 100644
index 0000000..bc8ae67
--- /dev/null
+++ b/tools/LdapProbe/ProbeSettings.cs
@@ -0,0 +1,201 @@
+using ZB.MOM.WW.Auth.Abstractions.Ldap;
+
+namespace ZB.MOM.WW.Tools.LdapProbe;
+
+///
+/// Effective probe configuration. Defaults come from scadaproj/ldap_setup.md
+/// (the audited Ignition NAM profile), so a bare run already points at production.
+///
+public sealed class ProbeSettings
+{
+ // --- directory / connection -------------------------------------------------
+ public string Server { get; set; } = "uswap-vdc-nam1.nam.zimmer.com";
+ public List Fallbacks { get; set; } = ["uswap-vdc-nam2.nam.zimmer.com"];
+ public int? PortOverride { get; set; }
+ public LdapTransport Transport { get; set; } = LdapTransport.Ldaps;
+ public bool Insecure { get; set; }
+ public int TimeoutMs { get; set; } = 15_000;
+
+ /// Port actually used: explicit override, else 636 for LDAPS and 389 otherwise.
+ public int Port => PortOverride ?? (Transport == LdapTransport.Ldaps ? 636 : 389);
+
+ // --- search -----------------------------------------------------------------
+ public string SearchBase { get; set; } = "DC=nam,DC=zimmer,DC=com";
+ public string UserNameAttribute { get; set; } = "sAMAccountName";
+ public string DisplayNameAttribute { get; set; } = "name";
+ public string GroupAttribute { get; set; } = "memberOf";
+
+ // --- identities -------------------------------------------------------------
+ /// Service-account bind identity. AD accepts a UPN here for a simple bind.
+ public string ServiceAccount { get; set; } = "svc_igntnapp@nam.zimmer.com";
+ public string? ServiceAccountPassword { get; set; }
+
+ ///
+ /// When true the probe binds as the end user for the raw dump instead of the service
+ /// account. Lets you get group data without holding the svc_igntnapp password — at the
+ /// cost of skipping Part A, which structurally requires a service account.
+ ///
+ public bool SelfBind { get; set; }
+
+ /// UPN suffix appended to a bare username for the self-bind and the UPN probe.
+ public string UpnSuffix { get; set; } = "@nam.zimmer.com";
+
+ // --- behaviour --------------------------------------------------------------
+ public string? Username { get; set; }
+ public bool SweepTransports { get; set; }
+
+ ///
+ /// When set, additionally enumerates every group in the directory whose cn matches
+ /// this wildcard pattern (e.g. UG_WW_*). Answers "what groups exist", which a
+ /// membership dump structurally cannot — it only ever shows the groups one account is in.
+ ///
+ public string? ListGroupsPattern { get; set; }
+
+ ///
+ /// Base for the group enumeration. Defaults to the domain root derived from
+ /// , because groups are spread across several containers and a
+ /// narrower base under-reports silently.
+ ///
+ public string? GroupBase { get; set; }
+ public bool ShowCertificate { get; set; } = true;
+ public bool Verbose { get; set; }
+
+ /// Builds the options object the shared library will be driven with.
+ public LdapOptions ToLdapOptions(RemoteCertificateValidationCallbackHolder certs) => new()
+ {
+ Enabled = true,
+ Server = Server,
+ Port = Port,
+ FallbackServers = Fallbacks.ToArray(),
+ Transport = Transport,
+ AllowInsecure = Insecure,
+ SearchBase = SearchBase,
+ ServiceAccountDn = ServiceAccount,
+ ServiceAccountPassword = ServiceAccountPassword ?? "",
+ UserNameAttribute = UserNameAttribute,
+ DisplayNameAttribute = DisplayNameAttribute,
+ GroupAttribute = GroupAttribute,
+ ConnectionTimeoutMs = TimeoutMs,
+ ServerCertificateValidationCallback = certs.Callback,
+ };
+
+ public static ProbeSettings Parse(string[] args, out bool showHelp)
+ {
+ var s = new ProbeSettings();
+ showHelp = false;
+
+ string Next(ref int i, string flag) =>
+ ++i < args.Length ? args[i] : throw new ArgumentException($"{flag} requires a value.");
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i].ToLowerInvariant())
+ {
+ case "-h" or "--help": showHelp = true; break;
+ case "-u" or "--user": s.Username = Next(ref i, "--user"); break;
+ case "-s" or "--server": s.Server = Next(ref i, "--server"); break;
+ case "--fallback":
+ var fb = Next(ref i, "--fallback");
+ s.Fallbacks = fb.Equals("none", StringComparison.OrdinalIgnoreCase)
+ ? []
+ : [.. fb.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
+ break;
+ case "-p" or "--port": s.PortOverride = int.Parse(Next(ref i, "--port")); break;
+ case "-t" or "--transport":
+ var t = Next(ref i, "--transport");
+ s.Transport = t.ToLowerInvariant() switch
+ {
+ "ldaps" => LdapTransport.Ldaps,
+ "starttls" => LdapTransport.StartTls,
+ "none" or "plain" => LdapTransport.None,
+ _ => throw new ArgumentException($"Unknown transport '{t}' (ldaps|starttls|none).")
+ };
+ break;
+ case "--insecure": s.Insecure = true; break;
+ case "--sweep": s.SweepTransports = true; break;
+ case "-b" or "--base": s.SearchBase = Next(ref i, "--base"); break;
+ case "--user-attr": s.UserNameAttribute = Next(ref i, "--user-attr"); break;
+ case "--group-attr": s.GroupAttribute = Next(ref i, "--group-attr"); break;
+ case "--display-attr": s.DisplayNameAttribute = Next(ref i, "--display-attr"); break;
+ case "--svc": s.ServiceAccount = Next(ref i, "--svc"); break;
+ case "--self-bind": s.SelfBind = true; break;
+ case "--upn-suffix": s.UpnSuffix = Next(ref i, "--upn-suffix"); break;
+ case "--timeout": s.TimeoutMs = int.Parse(Next(ref i, "--timeout")); break;
+ case "-g" or "--list-groups": s.ListGroupsPattern = Next(ref i, "--list-groups"); break;
+ case "--group-base": s.GroupBase = Next(ref i, "--group-base"); break;
+ case "-v" or "--verbose": s.Verbose = true; break;
+ default:
+ if (args[i].StartsWith('-'))
+ throw new ArgumentException($"Unknown option '{args[i]}'.");
+ s.Username ??= args[i];
+ break;
+ }
+ }
+
+ // Env fallback for the service-account secret so it never has to sit in shell history.
+ s.ServiceAccountPassword ??= Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PASSWORD");
+ return s;
+ }
+
+ public const string Help = """
+ ldapprobe — verify ZB / NAM Active Directory auth and dump a user's group membership.
+
+ USAGE
+ dotnet run --project tools/LdapProbe -- [options] [username]
+
+ The probe runs two independent halves:
+ Part A the REAL ZB.MOM.WW.Auth.Ldap library, so its verdict is exactly what
+ ScadaBridge / OtOpcUa / MxGateway / HistorianGateway will produce.
+ Part B a direct query that answers what the library cannot: full group DNs,
+ nested (transitive) groups, the primary group, and referral behaviour.
+
+ CONNECTION
+ -s, --server primary DC (default uswap-vdc-nam1.nam.zimmer.com)
+ --fallback backup DCs, or 'none' (default uswap-vdc-nam2.nam.zimmer.com)
+ -p, --port default 636 for ldaps, else 389
+ -t, --transport ldaps | starttls | none (default ldaps)
+ --insecure accept any server certificate. Needed off-domain: the DC
+ certs chain to the internal 'zimmer-rootca', which is not
+ in this machine's trust store.
+ --sweep try all three transports and report which connect
+ --timeout default 15000
+
+ SEARCH
+ -b, --base default DC=nam,DC=zimmer,DC=com
+ --user-attr login attribute (default sAMAccountName)
+ --group-attr group attribute (default memberOf)
+ --display-attr display name (default name)
+
+ GROUP ENUMERATION
+ -g, --list-groups also list EVERY group in the directory whose cn matches a
+ wildcard pattern, e.g. 'UG_WW_*'. A membership dump only
+ shows groups the test account is in; this shows what exists.
+ --group-base base for that search (default: the domain root derived from
+ --base, since groups sit in several containers)
+
+ IDENTITY
+ --svc service account (default svc_igntnapp@nam.zimmer.com)
+ --self-bind bind as the end user instead of the service account.
+ Use when you do not have the svc_igntnapp password;
+ Part A is skipped because it requires one.
+ --upn-suffix default @nam.zimmer.com
+ -u, --user sAMAccountName to test (otherwise prompted)
+
+ Passwords are always prompted for, never taken from the command line.
+ The service-account password may instead come from $ZB_LDAP_SVC_PASSWORD.
+
+ EXAMPLES
+ # first run off-domain, no service account to hand:
+ dotnet run --project tools/LdapProbe -- --insecure --self-bind jdoe
+
+ # full fidelity check, service account password from the environment:
+ export ZB_LDAP_SVC_PASSWORD='...'
+ dotnet run --project tools/LdapProbe -- --insecure jdoe
+
+ # which transports actually work from here?
+ dotnet run --project tools/LdapProbe -- --sweep --insecure --self-bind jdoe
+
+ # what UG_WW_* groups exist in the whole directory?
+ dotnet run --project tools/LdapProbe -- --insecure --self-bind -g 'UG_WW_*' jdoe
+ """;
+}
diff --git a/tools/LdapProbe/Program.cs b/tools/LdapProbe/Program.cs
new file mode 100644
index 0000000..0e57cd0
--- /dev/null
+++ b/tools/LdapProbe/Program.cs
@@ -0,0 +1,538 @@
+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();
+}
diff --git a/tools/LdapProbe/README.md b/tools/LdapProbe/README.md
new file mode 100644
index 0000000..7f9d5d9
--- /dev/null
+++ b/tools/LdapProbe/README.md
@@ -0,0 +1,100 @@
+# ldapprobe — ZB / NAM Active Directory auth + group probe
+
+A throwaway diagnostic for pinning down the LDAP settings the four ZB.MOM.WW apps need
+against the Zimmer NAM domain, and for answering the open question in
+[`../../ldap_setup.md`](../../ldap_setup.md): **which AD groups should we map to app roles?**
+
+The NAM directory has no established group→role convention (the Ignition gateway assigns
+roles per-user inside the application and ignores AD groups entirely), while
+`ZB.MOM.WW.Auth.Ldap` **fails closed on zero groups** — a user with a perfectly good
+password is refused if `memberOf` comes back empty. This probe exists to close that gap
+with data rather than guesswork.
+
+## Running it
+
+```bash
+cd ~/Desktop/scadaproj
+
+# no service-account password to hand — bind as the user under test:
+dotnet run --project tools/LdapProbe -- --insecure --self-bind
+
+# full fidelity, service account from the environment:
+export ZB_LDAP_SVC_PASSWORD='...'
+dotnet run --project tools/LdapProbe -- --insecure
+
+# which transports actually reach the DCs from here:
+dotnet run --project tools/LdapProbe -- --sweep --insecure -u x
+
+# what UG_WW_* groups exist across the whole directory:
+dotnet run --project tools/LdapProbe -- --insecure --self-bind -g 'UG_WW_*'
+
+dotnet run --project tools/LdapProbe -- --help
+```
+
+Quote the pattern — an unquoted `UG_WW_*` is glob-expanded by the shell.
+
+Passwords are always prompted for and never echoed. They cannot be passed on the command
+line — that would put them in shell history. The service-account password may come from
+`$ZB_LDAP_SVC_PASSWORD` instead.
+
+Requires the Zimmer VPN to be up: the DCs are `uswap-vdc-nam1` (10.201.216.2) and
+`uswap-vdc-nam2` (10.201.138.78).
+
+## What it reports
+
+**Part A** drives the real `ZB.MOM.WW.Auth.LdapAuthService`, so its verdict is exactly
+what ScadaBridge / OtOpcUa / MxGateway / HistorianGateway will produce — same bind-then-
+search-then-bind flow, same options validator, same failure enum, same fail-closed rules.
+Its five failure values are each explained in place, because `ServiceAccountBindFailed`
+in particular collapses three very different causes (connect/TLS, service-account bind,
+and the search itself) into one value.
+
+**Part B** asks the directory things the library does not, which is where the group
+answer comes from:
+
+| | why it matters |
+|---|---|
+| full user entry | `userAccountControl` decoded, so a disabled / locked / password-expired account is named rather than showing up as "bad credentials" |
+| **direct `memberOf`** | the *only* set the shared library sees |
+| **primary group** | AD omits it from `memberOf` — so it can never be used as a role group, and a user whose only group is `Domain Users` will always fail |
+| **transitive groups** | resolved server-side via `LDAP_MATCHING_RULE_IN_CHAIN`; a group reachable only by nesting is flagged, because mapping one would silently grant nobody anything |
+| group type | `security` vs `distribution`, and scope — decoded from `groupType` |
+| referral count | a search based at the domain root makes AD return continuation referrals — the cause of the `ServiceAccountBindFailed` fixed in Auth `0.2.1` |
+
+**`--list-groups `** adds a third, independent section: every group in the
+directory whose `cn` matches, not just the ones the test account belongs to. A membership
+dump structurally cannot answer "what groups exist to choose from" — it only ever shows
+one person's memberships, which for a long-tenured account skews heavily toward admin
+groups. Each row shows the group type, its member count (`EMPTY` and `N+` for AD's ranged
+1500-value cap are both called out) and whether the probed account holds it.
+
+It searches from the **domain root** by default, derived from the resolved user DN, because
+groups here live in at least three different containers and a narrower base under-reports
+without saying so.
+
+It closes with a ready-to-paste `appsettings` block, and — when the library failed
+system-side while a direct query succeeded — automatically retries Part A against the
+user's own container to test whether the domain-root search base is the culprit.
+
+## `--insecure`
+
+The DC certificates chain to the internal `zimmer-rootca`, which is not in a dev
+machine's trust store, so an honest run fails TLS validation. `--insecure` bypasses
+validation **for diagnosis only** and the probe says so on every run. The certificate
+inspector still prints the real chain, key size and `SslPolicyErrors` either way, so you
+can see precisely why validation failed rather than guessing.
+
+Note the two hazards are separate: an untrusted root affects every client, whereas the
+**RSA 1024-bit** DC keys are rejected only by OpenSSL at its default security level —
+i.e. by Debian-based .NET *containers*, not by macOS or Windows hosts.
+
+Before deploying, fix trust properly: install `zimmer-rootca` (+ `zimmer-issueca1`) into
+the host/container trust store, or pin the chain via
+`LdapOptions.ServerCertificateValidationCallback`. The emitted config block always sets
+`AllowInsecure: false` for that reason.
+
+## Not shipped
+
+This is a diagnostic, deliberately outside the `ZB.MOM.WW.Auth` solution and its central
+package management: `IsPackable=false`, no CI, no tests. It references the Auth projects
+directly rather than the `0.2.0` feed package so it always tracks current source.
diff --git a/tools/LdapProbe/RawDirectory.cs b/tools/LdapProbe/RawDirectory.cs
new file mode 100644
index 0000000..1954d2f
--- /dev/null
+++ b/tools/LdapProbe/RawDirectory.cs
@@ -0,0 +1,363 @@
+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));
+ }
+}
diff --git a/tools/LdapProbe/Ui.cs b/tools/LdapProbe/Ui.cs
new file mode 100644
index 0000000..ff65357
--- /dev/null
+++ b/tools/LdapProbe/Ui.cs
@@ -0,0 +1,85 @@
+namespace ZB.MOM.WW.Tools.LdapProbe;
+
+/// Console formatting and credential prompting.
+public static class Ui
+{
+ public static void Section(string title)
+ {
+ Console.WriteLine();
+ Write(ConsoleColor.Cyan, $"── {title} " + new string('─', Math.Max(0, 66 - title.Length)));
+ }
+
+ public static void Ok(string message) => Write(ConsoleColor.Green, " ✓ " + message);
+ public static void Bad(string message) => Write(ConsoleColor.Red, " ✗ " + message);
+ public static void Warn(string message) => Write(ConsoleColor.Yellow, " ! " + message);
+ public static void Info(string message) => Console.WriteLine(" " + message);
+
+ public static void Field(string name, string? value) =>
+ Console.WriteLine($" {name,-22} {value ?? "(not set)"}");
+
+ private static void Write(ConsoleColor color, string message)
+ {
+ var previous = Console.ForegroundColor;
+ try
+ {
+ Console.ForegroundColor = color;
+ Console.WriteLine(message);
+ }
+ finally
+ {
+ Console.ForegroundColor = previous;
+ }
+ }
+
+ public static string Prompt(string label, string? preset = null)
+ {
+ if (!string.IsNullOrWhiteSpace(preset)) return preset;
+ Console.Write($" {label}: ");
+ return Console.ReadLine()?.Trim() ?? "";
+ }
+
+ ///
+ /// Reads a password without echoing it. Falls back to a plain read when input is
+ /// redirected (a pipe has no key events), so the probe still works non-interactively.
+ ///
+ public static string PromptSecret(string label)
+ {
+ Console.Write($" {label}: ");
+
+ if (Console.IsInputRedirected)
+ {
+ var piped = Console.ReadLine() ?? "";
+ Console.WriteLine("(read from stdin)");
+ return piped;
+ }
+
+ var buffer = new Stack();
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+ switch (key.Key)
+ {
+ case ConsoleKey.Enter:
+ Console.WriteLine();
+ return new string(buffer.Reverse().ToArray());
+ case ConsoleKey.Backspace when buffer.Count > 0:
+ buffer.Pop();
+ Console.Write("\b \b");
+ break;
+ case ConsoleKey.Backspace:
+ break;
+ case ConsoleKey.Escape:
+ Console.WriteLine();
+ return "";
+ default:
+ if (!char.IsControl(key.KeyChar))
+ {
+ buffer.Push(key.KeyChar);
+ Console.Write('*');
+ }
+
+ break;
+ }
+ }
+ }
+}