From 4e01cbc2c378531a3cc4c846f27d83cc0180b80a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 12 Aug 2026 21:27:14 -0400 Subject: [PATCH] feat(tools): ldapprobe -- NAM AD auth + group discovery probe Diagnostic for pinning down the LDAP settings the four ZB.MOM.WW apps need against the Zimmer NAM domain, and for answering which AD groups can be mapped to app roles. Found the referral defect fixed in 0.2.1. Two independent halves in one run: Part A drives the REAL ZB.MOM.WW.Auth.LdapAuthService -- same bind-then-search- then-bind, same options validator, same failure enum -- so its verdict IS what the apps produce rather than an approximation of it. Part B asks the directory what the library cannot, which is where the group answer comes from: full group DNs, nested groups via LDAP_MATCHING_RULE_IN_CHAIN, the primary group (AD omits it from memberOf, so it can never be a role group), security vs distribution type, decoded userAccountControl, referral counts, and the TLS chain with key sizes. --list-groups '' enumerates every matching group in the directory, which a membership dump structurally cannot do -- it only ever shows one account's memberships. Passwords are prompted for and never echoed; they cannot be passed as arguments, which would put them in shell history. --self-bind lets the user under test stand in for the service account, so group discovery does not require the svc_igntnapp secret. Not packable, outside the Auth solution and its central package management, referencing the Auth projects directly so it tracks current source. --- tools/LdapProbe/CertificateInspector.cs | 124 ++++++ tools/LdapProbe/ConsoleLogger.cs | 32 ++ tools/LdapProbe/LdapProbe.csproj | 33 ++ tools/LdapProbe/ProbeSettings.cs | 201 +++++++++ tools/LdapProbe/Program.cs | 538 ++++++++++++++++++++++++ tools/LdapProbe/README.md | 100 +++++ tools/LdapProbe/RawDirectory.cs | 363 ++++++++++++++++ tools/LdapProbe/Ui.cs | 85 ++++ 8 files changed, 1476 insertions(+) create mode 100644 tools/LdapProbe/CertificateInspector.cs create mode 100644 tools/LdapProbe/ConsoleLogger.cs create mode 100644 tools/LdapProbe/LdapProbe.csproj create mode 100644 tools/LdapProbe/ProbeSettings.cs create mode 100644 tools/LdapProbe/Program.cs create mode 100644 tools/LdapProbe/README.md create mode 100644 tools/LdapProbe/RawDirectory.cs create mode 100644 tools/LdapProbe/Ui.cs 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; + } + } + } +}