using ADBulkTool.Models; using Novell.Directory.Ldap; using System.Globalization; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; namespace ADBulkTool.Services; public sealed class AdService : IDisposable { private const int AccountDisableFlag = 0x0002; private readonly AdConfig _config; private readonly LdapConnection _connection; private bool _connected; public AdService(AdConfig config) { _config = config; _connection = new LdapConnection { SecureSocketLayer = config.UseLdaps, ConnectionTimeout = 10000 }; if (config.IgnoreCertificateErrors) { #pragma warning disable CS0618 _connection.UserDefinedServerCertValidationDelegate += (_, _, _, _) => true; #pragma warning restore CS0618 } } public async Task ConnectAsync(CancellationToken cancellationToken = default) { if (_connected) { return; } var protocol = _config.UseLdaps ? "LDAPS" : "LDAP"; try { await _connection.ConnectAsync(_config.Host, _config.Port, cancellationToken); } catch (Exception ex) { var hint = _config.UseLdaps ? "Check TCP 636, the domain controller LDAPS certificate, and whether this Linux machine trusts the issuing CA." : "Check TCP 389, DNS, VPN/internal network, and firewall rules."; throw new InvalidOperationException($"{protocol} connect failed to {_config.Host}:{_config.Port}. {hint}", ex); } try { await _connection.BindAsync(_config.BindUsername, _config.BindPassword, cancellationToken); } catch (Exception ex) { throw new InvalidOperationException($"{protocol} bind failed for {_config.BindUsername} on {_config.Host}:{_config.Port}. Check credentials and account permissions.", ex); } _connected = true; } public async Task FindUserAsync(string csvValue, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); var identity = NormalizeCsvIdentity(csvValue); if (string.IsNullOrWhiteSpace(identity)) { return null; } var escaped = EscapeLdapFilter(identity); var filter = $"(&(objectClass=user)(objectCategory=person)(|(sAMAccountName={escaped})(userPrincipalName={escaped})))"; var attrs = new[] { "distinguishedName", "sAMAccountName", "userAccountControl" }; var results = await _connection.SearchAsync( _config.SearchBaseDn, LdapConnection.ScopeSub, filter, attrs, false, cancellationToken); await using var enumerator = results.GetAsyncEnumerator(); if (!await enumerator.MoveNextAsync()) { return null; } var entry = enumerator.Current; var dn = GetString(entry, "distinguishedName") ?? entry.Dn; var sam = GetString(entry, "sAMAccountName") ?? identity; var uacText = GetString(entry, "userAccountControl") ?? "512"; var uac = int.TryParse(uacText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedUac) ? parsedUac : 512; return new UserLookupResult(dn, sam, uac); } public async Task FindGroupDnAsync(string groupNameOrDn, CancellationToken cancellationToken = default) { await EnsureConnectedAsync(cancellationToken); var value = groupNameOrDn.Trim(); if (string.IsNullOrWhiteSpace(value)) { return null; } if (LooksLikeDistinguishedName(value)) { return value; } var escaped = EscapeLdapFilter(value); var filter = $"(&(objectClass=group)(|(cn={escaped})(sAMAccountName={escaped})(name={escaped})))"; var attrs = new[] { "distinguishedName" }; var results = await _connection.SearchAsync( _config.SearchBaseDn, LdapConnection.ScopeSub, filter, attrs, false, cancellationToken); await using var enumerator = results.GetAsyncEnumerator(); if (!await enumerator.MoveNextAsync()) { return null; } return GetString(enumerator.Current, "distinguishedName") ?? enumerator.Current.Dn; } public async Task DisableUserAsync(UserLookupResult user, CancellationToken cancellationToken = default) { var newValue = user.UserAccountControl | AccountDisableFlag; await ReplaceAttributeAsync(user.DistinguishedName, "userAccountControl", newValue.ToString(CultureInfo.InvariantCulture), cancellationToken); } public async Task EnableUserAsync(UserLookupResult user, CancellationToken cancellationToken = default) { var newValue = user.UserAccountControl & ~AccountDisableFlag; await ReplaceAttributeAsync(user.DistinguishedName, "userAccountControl", newValue.ToString(CultureInfo.InvariantCulture), cancellationToken); } public async Task AddUserToGroupAsync(string userDn, string groupDn, CancellationToken cancellationToken = default) { var mod = new LdapModification(LdapModification.Add, new LdapAttribute("member", userDn)); await _connection.ModifyAsync(groupDn, mod, cancellationToken); } public async Task RemoveUserFromGroupAsync(string userDn, string groupDn, CancellationToken cancellationToken = default) { var mod = new LdapModification(LdapModification.Delete, new LdapAttribute("member", userDn)); await _connection.ModifyAsync(groupDn, mod, cancellationToken); } public async Task ForcePasswordChangeAtNextLogonAsync(string userDn, CancellationToken cancellationToken = default) { await ReplaceAttributeAsync(userDn, "pwdLastSet", "0", cancellationToken); } public async Task SetPasswordAsync(string userDn, string newPassword, CancellationToken cancellationToken = default) { if (!_config.UseLdaps) { throw new InvalidOperationException("Password reset requires LDAPS. Click 'Use LDAPS 636', test it, then run this action again."); } var quotedPassword = $"\"{newPassword}\""; var passwordBytes = Encoding.Unicode.GetBytes(quotedPassword); var mod = new LdapModification(LdapModification.Replace, new LdapAttribute("unicodePwd", passwordBytes)); await _connection.ModifyAsync(userDn, mod, cancellationToken); } public static async Task ResolveHostAsync(string host, TimeSpan timeout, CancellationToken cancellationToken = default) { var addresses = await Dns.GetHostAddressesAsync(host).WaitAsync(timeout, cancellationToken); if (addresses.Length == 0) { throw new InvalidOperationException("No DNS addresses returned."); } return string.Join(", ", addresses.Select(a => a.ToString())); } public static async Task<(bool IsOpen, string Message)> TestTcpPortAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default) { try { using var client = new TcpClient(); await client.ConnectAsync(host, port).WaitAsync(timeout, cancellationToken); return (true, "open"); } catch (TimeoutException) { return (false, "timeout"); } catch (SocketException ex) { return (false, ex.Message); } catch (Exception ex) { return (false, ex.Message); } } public static string GenerateRandomPassword(int length = 20) { const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; const string lower = "abcdefghijkmnopqrstuvwxyz"; const string digits = "23456789"; const string symbols = "!#$%+-_?@"; var all = upper + lower + digits + symbols; var chars = new List { Pick(upper), Pick(lower), Pick(digits), Pick(symbols) }; while (chars.Count < length) { chars.Add(Pick(all)); } Shuffle(chars); return new string(chars.ToArray()); } public static string NormalizeCsvIdentity(string raw) { var value = (raw ?? string.Empty).Trim(); var slashIndex = value.LastIndexOf('\\'); if (slashIndex >= 0 && slashIndex < value.Length - 1) { return value[(slashIndex + 1)..].Trim(); } return value; } private async Task ReplaceAttributeAsync(string dn, string attributeName, string value, CancellationToken cancellationToken) { await EnsureConnectedAsync(cancellationToken); var mod = new LdapModification(LdapModification.Replace, new LdapAttribute(attributeName, value)); await _connection.ModifyAsync(dn, mod, cancellationToken); } private async Task EnsureConnectedAsync(CancellationToken cancellationToken) { if (!_connected) { await ConnectAsync(cancellationToken); } } private static bool LooksLikeDistinguishedName(string value) { return value.Contains("=", StringComparison.Ordinal) && value.Contains(",", StringComparison.Ordinal); } private static string? GetString(LdapEntry entry, string attributeName) { try { return entry.GetStringValueOrDefault(attributeName); } catch { return null; } } private static string EscapeLdapFilter(string value) { var sb = new StringBuilder(value.Length); foreach (var c in value) { switch (c) { case '\\': sb.Append(@"\5c"); break; case '*': sb.Append(@"\2a"); break; case '(' : sb.Append(@"\28"); break; case ')' : sb.Append(@"\29"); break; case '\0': sb.Append(@"\00"); break; default: sb.Append(c); break; } } return sb.ToString(); } private static char Pick(string source) { var index = RandomNumberGenerator.GetInt32(source.Length); return source[index]; } private static void Shuffle(IList chars) { for (var i = chars.Count - 1; i > 0; i--) { var j = RandomNumberGenerator.GetInt32(i + 1); (chars[i], chars[j]) = (chars[j], chars[i]); } } public void Dispose() { if (_connection.Connected) { _connection.Disconnect(); } _connection.Dispose(); } }