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