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