Initial AD bulk tool prototype

This commit is contained in:
mikisoq
2026-07-29 01:29:32 -01:00
parent 542aade40c
commit acc2fe0a8a
12 changed files with 1372 additions and 1 deletions
+718
View File
@@ -0,0 +1,718 @@
using ADBulkTool.Models;
using ADBulkTool.Services;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using CsvHelper;
using System.Globalization;
namespace ADBulkTool;
public partial class MainWindow : Window
{
private readonly List<string> _headers = new();
private readonly List<string[]> _rows = new();
private string _loadedFileName = string.Empty;
public MainWindow()
{
InitializeComponent();
UseLdapsBox.IsCheckedChanged += UseLdapsBox_Changed;
ActionBox.ItemsSource = BulkActionNames.All;
ActionBox.SelectedIndex = 0;
LdapPortBox.Text = "636";
SearchBaseBox.Text = "DC=knno,DC=local";
UseLdapsBox.IsChecked = true;
RefreshConnectionHelp();
RefreshActionHelp();
}
private async void LoadCsv_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
SetStatus("Could not open the file picker.");
return;
}
try
{
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open CSV file",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("CSV files") { Patterns = new[] { "*.csv" } },
FilePickerFileTypes.All
}
});
if (files.Count == 0)
{
return;
}
await LoadCsvAsync(files[0]);
}
catch (Exception ex)
{
SetStatus($"CSV load failed: {FormatExceptionMessage(ex)}");
}
}
private async Task LoadCsvAsync(IStorageFile file)
{
_headers.Clear();
_rows.Clear();
_loadedFileName = file.Name;
await using var stream = await file.OpenReadAsync();
using var reader = new StreamReader(stream);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
if (!csv.Read())
{
SetStatus("CSV appears to be empty.");
return;
}
csv.ReadHeader();
if (csv.HeaderRecord is null || csv.HeaderRecord.Length == 0)
{
SetStatus("CSV has no header row.");
return;
}
_headers.AddRange(csv.HeaderRecord);
while (csv.Read())
{
var values = new string[_headers.Count];
for (var i = 0; i < _headers.Count; i++)
{
values[i] = SafeGetCsvField(csv, i);
}
_rows.Add(values);
}
var options = _headers
.Select((header, index) => new ColumnOption(index, header))
.ToList();
UsernameColumnBox.ItemsSource = options;
UsernameColumnBox.SelectedIndex = options.Count > 0 ? 0 : -1;
CsvInfoBlock.Text = $"Loaded {_loadedFileName}: {_rows.Count} rows, {_headers.Count} columns.";
PreviewList.ItemsSource = options.Select(o => $"Column {o.Index + 1}: {o.Header}").ToList();
SetStatus("CSV loaded. Choose the username column and action, then click Preview.");
}
private async void TestConnection_Click(object? sender, RoutedEventArgs e)
{
try
{
var host = LdapHostBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(host))
{
SetStatus("LDAP host is required, for example NODC04.knno.local.");
return;
}
SetBusy(true);
SetStatus("Testing DNS, ports, LDAP 389, and LDAPS 636...");
var selectedPort = TryParsePort(LdapPortBox.Text);
var lines = await RunConnectionHealthCheckAsync(
host,
selectedPort,
UseLdapsBox.IsChecked == true,
IgnoreCertBox.IsChecked == true,
SearchBaseBox.Text?.Trim() ?? string.Empty,
BindUserBox.Text?.Trim() ?? string.Empty,
BindPasswordBox.Text ?? string.Empty);
PreviewList.ItemsSource = lines;
var ldapOk = lines.Any(l => l.StartsWith("OK: LDAP bind on 389", StringComparison.OrdinalIgnoreCase));
var ldapsOk = lines.Any(l => l.StartsWith("OK: LDAPS bind on 636", StringComparison.OrdinalIgnoreCase));
if (ldapsOk)
{
SetStatus("Health check done. LDAPS works, so password reset actions are available.");
}
else if (ldapOk)
{
SetStatus("Health check done. LDAP works for normal edits, but LDAPS failed or was not available for password resets.");
}
else
{
SetStatus("Health check done. No successful LDAP bind found. Check VPN/network, DNS, firewall, credentials, or certificate trust.");
}
}
catch (Exception ex)
{
SetStatus($"LDAP health check failed: {FormatExceptionMessage(ex)}");
}
finally
{
SetBusy(false);
}
}
private void Preview_Click(object? sender, RoutedEventArgs e)
{
var validationError = ValidateCsvAndAction(dryRun: true);
if (validationError is not null)
{
SetStatus(validationError);
return;
}
var lines = BuildLocalPreview(100);
PreviewList.ItemsSource = lines;
SetStatus($"Preview generated for {Math.Min(_rows.Count, 100)} of {_rows.Count} rows. This preview does not contact AD.");
}
private async void Execute_Click(object? sender, RoutedEventArgs e)
{
var dryRun = DryRunBox.IsChecked == true;
var validationError = ValidateCsvAndAction(dryRun);
if (validationError is not null)
{
SetStatus(validationError);
return;
}
try
{
var config = BuildConfig();
var action = GetSelectedAction();
var usernameColumn = GetSelectedColumn();
var groupValue = GroupBox.Text?.Trim() ?? string.Empty;
SetBusy(true);
SetStatus(dryRun ? "Running AD dry run..." : "Executing AD changes...");
var results = await RunBulkAsync(config, usernameColumn.Index, action, groupValue, dryRun);
PreviewList.ItemsSource = results;
var failures = results.Count(r => r.StartsWith("FAIL", StringComparison.OrdinalIgnoreCase));
var successes = results.Count(r => r.StartsWith("OK", StringComparison.OrdinalIgnoreCase));
var dryRuns = results.Count(r => r.StartsWith("DRY RUN", StringComparison.OrdinalIgnoreCase));
SetStatus($"Done. OK: {successes}. Dry run: {dryRuns}. Failed: {failures}.");
}
catch (Exception ex)
{
SetStatus($"Run failed: {FormatExceptionMessage(ex)}");
}
finally
{
SetBusy(false);
}
}
private void UsePlainLdap_Click(object? sender, RoutedEventArgs e)
{
UseLdapsBox.IsChecked = false;
LdapPortBox.Text = "389";
IgnoreCertBox.IsChecked = false;
RefreshConnectionHelp();
RefreshActionHelp();
SetStatus("Selected LDAP 389. Normal non-password edits can work; password reset actions are blocked.");
}
private void UseLdapsPreset_Click(object? sender, RoutedEventArgs e)
{
UseLdapsBox.IsChecked = true;
LdapPortBox.Text = "636";
RefreshConnectionHelp();
RefreshActionHelp();
SetStatus("Selected LDAPS 636. Use this for password reset actions.");
}
private void UseLdapsBox_Changed(object? sender, RoutedEventArgs e)
{
if (LdapPortBox is null || IgnoreCertBox is null)
{
return;
}
if (UseLdapsBox.IsChecked == true)
{
LdapPortBox.Text = "636";
IgnoreCertBox.IsEnabled = true;
}
else
{
LdapPortBox.Text = "389";
IgnoreCertBox.IsChecked = false;
IgnoreCertBox.IsEnabled = false;
}
RefreshConnectionHelp();
RefreshActionHelp();
}
private void ActionBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
RefreshActionHelp();
}
private async Task<List<string>> RunConnectionHealthCheckAsync(
string host,
int? selectedPort,
bool selectedUseLdaps,
bool ignoreCertificateErrors,
string searchBase,
string bindUser,
string bindPassword)
{
var output = new List<string>
{
$"Testing host: {host}",
"This checks DNS, TCP ports 389/636, plain LDAP bind, and LDAPS bind."
};
try
{
var resolved = await AdService.ResolveHostAsync(host, TimeSpan.FromSeconds(5));
output.Add($"OK: DNS resolved {host} to {resolved}");
}
catch (Exception ex)
{
output.Add($"FAIL: DNS lookup failed for {host}: {FormatExceptionMessage(ex)}");
output.Add("Hint: check DNS suffix, VPN/internal network, or try the full DC FQDN such as nodc04.knno.local.");
}
var tcp389 = await AdService.TestTcpPortAsync(host, 389, TimeSpan.FromSeconds(5));
output.Add(tcp389.IsOpen
? "OK: TCP port 389 is reachable."
: $"FAIL: TCP port 389 is not reachable: {tcp389.Message}");
var tcp636 = await AdService.TestTcpPortAsync(host, 636, TimeSpan.FromSeconds(5));
output.Add(tcp636.IsOpen
? "OK: TCP port 636 is reachable."
: $"FAIL: TCP port 636 is not reachable: {tcp636.Message}");
if (selectedPort.HasValue && selectedPort.Value != 389 && selectedPort.Value != 636)
{
var selected = await AdService.TestTcpPortAsync(host, selectedPort.Value, TimeSpan.FromSeconds(5));
var protocol = selectedUseLdaps ? "LDAPS" : "LDAP";
output.Add(selected.IsOpen
? $"OK: selected {protocol} port {selectedPort.Value} is reachable."
: $"FAIL: selected {protocol} port {selectedPort.Value} is not reachable: {selected.Message}");
}
if (string.IsNullOrWhiteSpace(bindUser) || string.IsNullOrWhiteSpace(bindPassword))
{
output.Add("INFO: Bind tests skipped because bind username or password is empty. Port tests still ran.");
output.Add("Recommendation: enter your bind username/password and run this test again before giving the tool to co-workers.");
return output;
}
if (tcp389.IsOpen)
{
var ldap389 = await TryBindAsync(new AdConfig(host, 389, false, false, searchBase, bindUser, bindPassword));
output.Add(ldap389.Success
? "OK: LDAP bind on 389 succeeded. Normal non-password edits can use this."
: $"FAIL: LDAP bind on 389 failed: {ldap389.Message}");
}
else
{
output.Add("INFO: LDAP bind on 389 skipped because TCP 389 is not reachable.");
}
if (tcp636.IsOpen)
{
var ldaps636 = await TryBindAsync(new AdConfig(host, 636, true, ignoreCertificateErrors, searchBase, bindUser, bindPassword));
output.Add(ldaps636.Success
? "OK: LDAPS bind on 636 succeeded. Password reset actions can use this."
: $"FAIL: LDAPS bind on 636 failed: {ldaps636.Message}");
if (!ldaps636.Success && !ignoreCertificateErrors)
{
output.Add("Hint: if TCP 636 works but LDAPS bind fails on Linux, the Linux machine may not trust your internal CA chain. For testing only, try 'Ignore cert errors'. For production, install the KNNO root/intermediate CA into Linux trust.");
}
}
else
{
output.Add("INFO: LDAPS bind on 636 skipped because TCP 636 is not reachable.");
}
output.Add("Summary: use LDAP 389 for enable/disable/group edits. Use LDAPS 636 for actions that set/reset passwords.");
return output;
}
private static async Task<(bool Success, string Message)> TryBindAsync(AdConfig config)
{
try
{
using var service = new AdService(config);
await service.ConnectAsync();
return (true, "Bind succeeded.");
}
catch (Exception ex)
{
return (false, FormatExceptionMessage(ex));
}
}
private async Task<List<string>> RunBulkAsync(AdConfig config, int usernameColumnIndex, string action, string groupValue, bool dryRun)
{
var output = new List<string>();
if (RequiresLdaps(action) && !config.UseLdaps)
{
if (!dryRun)
{
output.Add("FAIL: This action resets passwords and requires LDAPS. Click 'Use LDAPS 636', run 'Test ports + LDAP/LDAPS', then run again.");
return output;
}
output.Add("INFO: Dry run allowed. This action resets passwords, so real execution will require LDAPS 636.");
}
using var service = new AdService(config);
await service.ConnectAsync();
string? groupDn = null;
if (RequiresGroup(action))
{
groupDn = await service.FindGroupDnAsync(groupValue);
if (groupDn is null)
{
output.Add($"FAIL: group not found: {groupValue}");
return output;
}
output.Add($"INFO: group resolved to {groupDn}");
}
for (var i = 0; i < _rows.Count; i++)
{
var rowNumber = i + 2; // +1 for zero index, +1 for CSV header row
var rawIdentity = GetValue(_rows[i], usernameColumnIndex);
var normalizedIdentity = AdService.NormalizeCsvIdentity(rawIdentity);
if (string.IsNullOrWhiteSpace(normalizedIdentity))
{
output.Add($"FAIL row {rowNumber}: username value is empty.");
continue;
}
try
{
var user = await service.FindUserAsync(rawIdentity);
if (user is null)
{
output.Add($"FAIL row {rowNumber}: user not found: {rawIdentity}");
continue;
}
if (dryRun)
{
output.Add($"DRY RUN row {rowNumber}: would {action} for {user.SamAccountName} [{user.DistinguishedName}]");
continue;
}
switch (action)
{
case BulkActionNames.DisableUser:
await service.DisableUserAsync(user);
output.Add($"OK row {rowNumber}: disabled {user.SamAccountName}");
break;
case BulkActionNames.EnableUser:
await service.EnableUserAsync(user);
output.Add($"OK row {rowNumber}: enabled {user.SamAccountName}");
break;
case BulkActionNames.AddToGroup:
await service.AddUserToGroupAsync(user.DistinguishedName, groupDn!);
output.Add($"OK row {rowNumber}: added {user.SamAccountName} to {groupDn}");
break;
case BulkActionNames.RemoveFromGroup:
await service.RemoveUserFromGroupAsync(user.DistinguishedName, groupDn!);
output.Add($"OK row {rowNumber}: removed {user.SamAccountName} from {groupDn}");
break;
case BulkActionNames.SetRandomPassword:
{
var password = AdService.GenerateRandomPassword();
await service.SetPasswordAsync(user.DistinguishedName, password);
output.Add($"OK row {rowNumber}: set random password for {user.SamAccountName}. New password: {password}");
break;
}
case BulkActionNames.ForcePasswordChange:
await service.ForcePasswordChangeAtNextLogonAsync(user.DistinguishedName);
output.Add($"OK row {rowNumber}: set password change at next login for {user.SamAccountName}");
break;
case BulkActionNames.SetRandomPasswordAndForceChange:
{
var password = AdService.GenerateRandomPassword();
await service.SetPasswordAsync(user.DistinguishedName, password);
await service.ForcePasswordChangeAtNextLogonAsync(user.DistinguishedName);
output.Add($"OK row {rowNumber}: set random password and forced change for {user.SamAccountName}. New password: {password}");
break;
}
default:
output.Add($"FAIL row {rowNumber}: unknown action: {action}");
break;
}
}
catch (Exception ex)
{
output.Add($"FAIL row {rowNumber}: {normalizedIdentity}: {FormatExceptionMessage(ex)}");
}
}
return output;
}
private List<string> BuildLocalPreview(int maxRows)
{
var action = GetSelectedAction();
var column = GetSelectedColumn();
var groupValue = GroupBox.Text?.Trim() ?? string.Empty;
var lines = new List<string>();
if (RequiresLdaps(action) && UseLdapsBox.IsChecked != true)
{
lines.Add("WARNING: selected action resets passwords. Preview/dry run can be shown, but real execution requires LDAPS 636.");
}
for (var i = 0; i < Math.Min(_rows.Count, maxRows); i++)
{
var rawIdentity = GetValue(_rows[i], column.Index);
var normalizedIdentity = AdService.NormalizeCsvIdentity(rawIdentity);
var extra = RequiresGroup(action) ? $" | group: {groupValue}" : string.Empty;
lines.Add($"CSV row {i + 2}: {action} -> {rawIdentity} | AD lookup value: {normalizedIdentity}{extra}");
}
if (_rows.Count > maxRows)
{
lines.Add($"... {_rows.Count - maxRows} more rows not shown in preview.");
}
return lines;
}
private string? ValidateCsvAndAction(bool dryRun)
{
if (_rows.Count == 0 || _headers.Count == 0)
{
return "Load a CSV first.";
}
if (UsernameColumnBox.SelectedItem is not ColumnOption)
{
return "Choose the username column.";
}
var action = GetSelectedAction();
if (string.IsNullOrWhiteSpace(action))
{
return "Choose an action.";
}
if (RequiresGroup(action) && string.IsNullOrWhiteSpace(GroupBox.Text))
{
return "This action requires a group name or distinguished name.";
}
if (!dryRun && RequiresLdaps(action) && UseLdapsBox.IsChecked != true)
{
return "This action resets passwords and requires LDAPS. Click 'Use LDAPS 636', run 'Test ports + LDAP/LDAPS', then run again.";
}
return null;
}
private AdConfig BuildConfig()
{
var host = LdapHostBox.Text?.Trim() ?? string.Empty;
var portText = LdapPortBox.Text?.Trim() ?? (UseLdapsBox.IsChecked == true ? "636" : "389");
var searchBase = SearchBaseBox.Text?.Trim() ?? string.Empty;
var bindUser = BindUserBox.Text?.Trim() ?? string.Empty;
var bindPassword = BindPasswordBox.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(host))
{
throw new InvalidOperationException("LDAP host is required, for example NODC04.knno.local.");
}
if (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var port))
{
throw new InvalidOperationException("LDAP port must be a number, normally 389 for LDAP or 636 for LDAPS.");
}
if (string.IsNullOrWhiteSpace(searchBase))
{
throw new InvalidOperationException("Search base DN is required, for example DC=knno,DC=local.");
}
if (string.IsNullOrWhiteSpace(bindUser))
{
throw new InvalidOperationException("Bind username is required.");
}
if (string.IsNullOrWhiteSpace(bindPassword))
{
throw new InvalidOperationException("Bind password is required.");
}
return new AdConfig(
host,
port,
UseLdapsBox.IsChecked == true,
IgnoreCertBox.IsChecked == true,
searchBase,
bindUser,
bindPassword);
}
private ColumnOption GetSelectedColumn()
{
return (ColumnOption)UsernameColumnBox.SelectedItem!;
}
private string GetSelectedAction()
{
return ActionBox.SelectedItem as string ?? string.Empty;
}
private static bool RequiresGroup(string action)
{
return action is BulkActionNames.AddToGroup or BulkActionNames.RemoveFromGroup;
}
private static bool RequiresLdaps(string action)
{
return action is BulkActionNames.SetRandomPassword or BulkActionNames.SetRandomPasswordAndForceChange;
}
private void RefreshConnectionHelp()
{
if (ConnectionHelpBlock is null)
{
return;
}
if (UseLdapsBox.IsChecked == true)
{
ConnectionHelpBlock.Text = "Selected: LDAPS 636. This is required for password reset actions and can also be used for normal edits. If it fails on Linux, test certificate trust.";
}
else
{
ConnectionHelpBlock.Text = "Selected: LDAP 389. This can be used for normal non-password edits only. Password reset actions are blocked until LDAPS 636 is selected.";
}
}
private void RefreshActionHelp()
{
if (ActionHelpBlock is null)
{
return;
}
var action = GetSelectedAction();
if (RequiresLdaps(action) && UseLdapsBox.IsChecked != true)
{
ActionHelpBlock.Text = "Selected action resets passwords. It will only execute when LDAPS is selected. Dry run is still allowed.";
return;
}
if (RequiresLdaps(action))
{
ActionHelpBlock.Text = "Selected action resets passwords. LDAPS is selected; run 'Test ports + LDAP/LDAPS' before executing.";
return;
}
if (action == BulkActionNames.ForcePasswordChange)
{
ActionHelpBlock.Text = "This sets pwdLastSet=0 so the user must change password at next logon. It does not generate a new password.";
return;
}
if (RequiresGroup(action))
{
ActionHelpBlock.Text = "This action needs a group CN, sAMAccountName, name, or full distinguished name.";
return;
}
ActionHelpBlock.Text = "This action does not reset passwords and can run over LDAP 389 or LDAPS 636.";
}
private static string SafeGetCsvField(CsvReader csv, int index)
{
try
{
return csv.GetField(index) ?? string.Empty;
}
catch
{
return string.Empty;
}
}
private static string GetValue(string[] row, int index)
{
return index >= 0 && index < row.Length ? row[index] : string.Empty;
}
private static int? TryParsePort(string? text)
{
return int.TryParse(text?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
? port
: null;
}
private void SetBusy(bool busy)
{
LoadCsvButton.IsEnabled = !busy;
TestConnectionButton.IsEnabled = !busy;
PreviewButton.IsEnabled = !busy;
ExecuteButton.IsEnabled = !busy;
UsePlainLdapButton.IsEnabled = !busy;
UseLdapsPresetButton.IsEnabled = !busy;
}
private void SetStatus(string message)
{
StatusBlock.Text = message;
}
private static string FormatExceptionMessage(Exception ex)
{
var messages = new List<string>();
for (var current = ex; current is not null; current = current.InnerException)
{
if (!string.IsNullOrWhiteSpace(current.Message) && !messages.Contains(current.Message))
{
messages.Add(current.Message);
}
}
return string.Join(" | ", messages);
}
private sealed record ColumnOption(int Index, string Header)
{
public override string ToString()
{
return $"{Index + 1}: {Header}";
}
}
}