Initial AD bulk tool prototype

This commit is contained in:
mikisoq
2026-07-29 01:29:32 -01:00
parent 542aade40c
commit e65474beaf
12 changed files with 1372 additions and 1 deletions
+34
View File
@@ -0,0 +1,34 @@
bin/
obj/
.vs/
.vscode/*.user
*.user
*.suo
# Build output
*.dll
*.exe
*.pdb
# Logs and result exports
*.log
logs/
results/
Success.csv
Failed.csv
# Sensitive AD input files
*.csv
!samples/*.csv
# Certificates and secrets
*.pfx
*.p12
*.key
*.pem
*.cer
*.crt
# OS junk
.DS_Store
Thumbs.db
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.5" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="4.0.0" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ADBulkTool.App">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+23
View File
@@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace ADBulkTool;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}
+97
View File
@@ -0,0 +1,97 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ADBulkTool.MainWindow"
Title="AD Bulk Tool"
Width="1080"
Height="820"
MinWidth="940"
MinHeight="700">
<Grid Margin="14" RowDefinitions="Auto,Auto,Auto,*,Auto" RowSpacing="12">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="AD Bulk Tool" FontSize="22" FontWeight="Bold" />
<TextBlock Text="Prototype: load a CSV, map the username column, preview changes, then run a dry run or execute against AD. LDAP 389 can be used for normal edits; LDAPS 636 is required for password resets." TextWrapping="Wrap" />
</StackPanel>
<Border Grid.Row="1" BorderThickness="1" Padding="10" CornerRadius="6">
<Grid ColumnDefinitions="*,90,130,210" RowDefinitions="Auto,Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="8">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Text="Domain controller / LDAP host" />
<TextBox x:Name="LdapHostBox" PlaceholderText="dc01.contoso.local" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1">
<TextBlock Text="Port" />
<TextBox x:Name="LdapPortBox" Text="636" />
</StackPanel>
<CheckBox Grid.Row="0" Grid.Column="2" x:Name="UseLdapsBox" Content="Use LDAPS" IsChecked="True" VerticalAlignment="Bottom" />
<CheckBox Grid.Row="0" Grid.Column="3" x:Name="IgnoreCertBox" Content="Ignore cert errors (test only)" VerticalAlignment="Bottom" />
<StackPanel Grid.Row="1" Grid.Column="0">
<TextBlock Text="Search base DN" />
<TextBox x:Name="SearchBaseBox" PlaceholderText="DC=contoso,DC=local" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2">
<TextBlock Text="Bind username" />
<TextBox x:Name="BindUserBox" PlaceholderText="CONTOSO\adminuser or adminuser@contoso.local" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="3">
<TextBlock Text="Bind password" />
<TextBox x:Name="BindPasswordBox" PasswordChar="*" />
</StackPanel>
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Spacing="8">
<Button x:Name="UsePlainLdapButton" Content="Use LDAP 389 (normal edits)" Click="UsePlainLdap_Click" />
<Button x:Name="UseLdapsPresetButton" Content="Use LDAPS 636 (password edits)" Click="UseLdapsPreset_Click" />
</StackPanel>
<Button Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" x:Name="TestConnectionButton" Content="Test ports + LDAP/LDAPS" Click="TestConnection_Click" HorizontalAlignment="Left" />
<TextBlock Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="4" x:Name="ConnectionHelpBlock" Text="LDAP 389 can do normal non-password edits. LDAPS 636 is required for password reset actions." TextWrapping="Wrap" />
</Grid>
</Border>
<Border Grid.Row="2" BorderThickness="1" Padding="10" CornerRadius="6">
<Grid ColumnDefinitions="Auto,*,*,*,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="8">
<Button Grid.Row="0" Grid.Column="0" x:Name="LoadCsvButton" Content="Load CSV" Click="LoadCsv_Click" />
<StackPanel Grid.Row="0" Grid.Column="1">
<TextBlock Text="Username column" />
<ComboBox x:Name="UsernameColumnBox" MinWidth="160" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2">
<TextBlock Text="Action" />
<ComboBox x:Name="ActionBox" MinWidth="260" SelectionChanged="ActionBox_SelectionChanged" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="3">
<TextBlock Text="Group name or DN" />
<TextBox x:Name="GroupBox" PlaceholderText="Only for add/remove group" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="4" Orientation="Horizontal" Spacing="12" VerticalAlignment="Bottom">
<CheckBox x:Name="DryRunBox" Content="Dry run" IsChecked="True" />
<Button x:Name="PreviewButton" Content="Preview" Click="Preview_Click" />
<Button x:Name="ExecuteButton" Content="Run" Click="Execute_Click" />
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="5" x:Name="CsvInfoBlock" Text="No CSV loaded." />
<TextBlock Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="5" x:Name="ActionHelpBlock" Text="Choose an action. Password reset actions require LDAPS." TextWrapping="Wrap" />
</Grid>
</Border>
<Border Grid.Row="3" BorderThickness="1" Padding="10" CornerRadius="6">
<Grid RowDefinitions="Auto,*" RowSpacing="8">
<TextBlock Text="Preview / health check / results" FontWeight="Bold" />
<ListBox Grid.Row="1" x:Name="PreviewList" />
</Grid>
</Border>
<TextBlock Grid.Row="4" x:Name="StatusBlock" Text="Ready." />
</Grid>
</Window>
+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=contoso,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 dc01.contoso.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 dc01.contoso.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 CONTOSO 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 dc01.contoso.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=contoso,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}";
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ADBulkTool.Models;
public sealed record AdConfig(
string Host,
int Port,
bool UseLdaps,
bool IgnoreCertificateErrors,
string SearchBaseDn,
string BindUsername,
string BindPassword);
+23
View File
@@ -0,0 +1,23 @@
namespace ADBulkTool.Models;
public static class BulkActionNames
{
public const string DisableUser = "Disable user";
public const string EnableUser = "Enable user";
public const string AddToGroup = "Add to group";
public const string RemoveFromGroup = "Remove from group";
public const string SetRandomPassword = "Set random password";
public const string ForcePasswordChange = "Set password change at next login";
public const string SetRandomPasswordAndForceChange = "Set random password + change at next login";
public static readonly string[] All =
{
DisableUser,
EnableUser,
AddToGroup,
RemoveFromGroup,
SetRandomPassword,
ForcePasswordChange,
SetRandomPasswordAndForceChange
};
}
+6
View File
@@ -0,0 +1,6 @@
namespace ADBulkTool.Models;
public sealed record UserLookupResult(
string DistinguishedName,
string SamAccountName,
int UserAccountControl);
+22
View File
@@ -0,0 +1,22 @@
using Avalonia;
using System;
namespace ADBulkTool;
internal sealed class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}
+83 -1
View File
@@ -1,2 +1,84 @@
# ADBulk
# AD Bulk Tool prototype
A small Avalonia + C# prototype for bulk Active Directory edits from a CSV.
## What it currently does
- Load a CSV file.
- Show all CSV headers in a human-readable dropdown, including duplicate column names as `1: Name`, `2: Name`, etc.
- Select one of these actions:
- Disable user
- Enable user
- Add to group
- Remove from group
- Set random password
- Set password change at next login
- Set random password + change at next login
- Preview changes locally.
- Run a real AD dry run that binds to LDAP/LDAPS and validates users.
- Execute changes against AD.
- Test DNS, TCP 389, TCP 636, LDAP bind on 389, and LDAPS bind on 636 before running a job.
- Use two presets:
- `Use LDAP 389 (normal edits)`
- `Use LDAPS 636 (password edits)`
## Important safety notes
- Test with a dedicated test OU and test accounts first.
- Leave `Dry run` enabled until you are sure the mapping is correct.
- Normal edits such as enable, disable, add to group, and remove from group can use LDAP 389.
- Password reset actions require LDAPS 636 and are blocked if LDAP 389 is selected.
- `Set password change at next login` sets `pwdLastSet=0`; it does not generate a new password.
- Do not use `Ignore cert errors` in production. Install your internal root CA on the Linux machine instead.
- The prototype shows generated passwords in the result list. Treat the screen as sensitive.
## Run
```bash
dotnet restore
dotnet run
```
## Typical values
For normal edits:
```text
LDAP host: dc01.contoso.local
Port: 389
Use LDAPS: unchecked
Search base DN: DC=contoso,DC=local
Bind username: CONTOSO\your-admin-user or your-admin-user@contoso.local
```
For password reset actions:
```text
LDAP host: dc01.contoso.local
Port: 636
Use LDAPS: checked
Search base DN: DC=contoso,DC=local
Bind username: CONTOSO\your-admin-user or your-admin-user@contoso.local
```
## Recommended test order
1. Enter host, search base, bind username, and bind password.
2. Click `Test ports + LDAP/LDAPS`.
3. Confirm whether LDAP 389 works.
4. Confirm whether LDAPS 636 works.
5. Load a tiny CSV with test users.
6. Run `Preview`.
7. Run with `Dry run` checked.
8. Only then run without `Dry run`.
## Linux LDAPS trust
For proper LDAPS, your Linux machine must trust the CA that issued the domain controller LDAP certificate.
On Arch/CachyOS, this is commonly done by placing your root CA certificate under `/etc/ca-certificates/trust-source/anchors/` and running:
```bash
sudo trust extract-compat
```
Exact CA trust commands depend on your Linux distribution.
+332
View File
@@ -0,0 +1,332 @@
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<UserLookupResult?> 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<string?> 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<string> 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<char>
{
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<char> 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();
}
}