Compare commits
10
Commits
v1.0.0
...
1fbcf4fcdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbcf4fcdd | ||
|
|
67599fb1e5 | ||
|
|
7f950d05ed | ||
|
|
31d1526ffe | ||
|
|
bb32a78e1e | ||
|
|
fbbb708bfd | ||
|
|
b953b3f84a | ||
|
|
e6c67ec2b1 | ||
|
|
d10a4f47f5 | ||
|
|
acc2fe0a8a |
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build AD Bulk Tool
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, feature/**, bugfix/**]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
rid: [win-x64, linux-x64, osx-x64, osx-arm64]
|
||||||
|
include:
|
||||||
|
- rid: win-x64
|
||||||
|
os: ubuntu-latest
|
||||||
|
suffix: .exe
|
||||||
|
- rid: linux-x64
|
||||||
|
os: ubuntu-latest
|
||||||
|
suffix: ''
|
||||||
|
- rid: osx-x64
|
||||||
|
os: macos-latest
|
||||||
|
suffix: ''
|
||||||
|
- rid: osx-arm64
|
||||||
|
os: macos-latest
|
||||||
|
suffix: ''
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Publish (${{ matrix.rid }})
|
||||||
|
run: dotnet publish ADBulkTool.csproj -c Release -r ${{ matrix.rid }} --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/${{ matrix.rid }}
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ADBulkTool-${{ matrix.rid }}
|
||||||
|
path: publish/${{ matrix.rid }}/ADBulkTool${{ matrix.suffix }}
|
||||||
+34
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<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" />
|
||||||
|
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<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" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBox x:Name="LdapHostBox" PlaceholderText="dc01.contoso.local" />
|
||||||
|
<Button Grid.Column="1" x:Name="DiscoverButton" Content="Discover" Click="Discover_Click" />
|
||||||
|
</Grid>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
_ = AutoDiscoverAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Discover_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DiscoverButton.IsEnabled = false;
|
||||||
|
SetStatus("Discovering domain and domain controller from DNS...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await DomainDiscovery.DiscoverAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
if (result.LdapHost is not null)
|
||||||
|
{
|
||||||
|
LdapHostBox.Text = result.LdapHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.SearchBaseDn is not null)
|
||||||
|
{
|
||||||
|
SearchBaseBox.Text = result.SearchBaseDn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Domain is not null)
|
||||||
|
{
|
||||||
|
var domainUpper = result.Domain.ToUpperInvariant();
|
||||||
|
var domainParts = result.Domain.Split('.');
|
||||||
|
var netbios = domainParts.Length > 0 ? domainParts[0].ToUpperInvariant() : domainUpper;
|
||||||
|
var userPlaceholder = $@"{netbios}\adminuser or adminuser@{result.Domain}";
|
||||||
|
BindUserBox.PlaceholderText = userPlaceholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetStatus(result.Message ?? "Discovery did not return a result.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus($"Discovery failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DiscoverButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AutoDiscoverAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await DomainDiscovery.DiscoverAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (result.LdapHost is not null && string.IsNullOrWhiteSpace(LdapHostBox.Text))
|
||||||
|
{
|
||||||
|
LdapHostBox.Text = result.LdapHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.SearchBaseDn is not null && string.IsNullOrWhiteSpace(SearchBaseBox.Text))
|
||||||
|
{
|
||||||
|
SearchBaseBox.Text = result.SearchBaseDn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.LdapHost is not null || result.SearchBaseDn is not null)
|
||||||
|
{
|
||||||
|
SetStatus("Auto-discovered AD domain from DNS. Adjust if needed, then enter credentials.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 your internal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ADBulkTool.Models;
|
||||||
|
|
||||||
|
public sealed record DiscoveryResult(
|
||||||
|
string? Domain,
|
||||||
|
string? DnsServer,
|
||||||
|
string? LdapHost,
|
||||||
|
int? LdapPort,
|
||||||
|
string? SearchBaseDn,
|
||||||
|
string? Message);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADBulkTool.Models;
|
||||||
|
|
||||||
|
public sealed record UserLookupResult(
|
||||||
|
string DistinguishedName,
|
||||||
|
string SamAccountName,
|
||||||
|
int UserAccountControl);
|
||||||
+22
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,103 @@
|
|||||||
# ADBulk
|
# AD Bulk Tool
|
||||||
|
|
||||||
|
Cross-platform Avalonia UI app for bulk Active Directory operations from a CSV file.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Load a CSV with any column structure
|
||||||
|
- Auto-discover the AD domain and domain controller via DNS (click **Discover** or runs on startup)
|
||||||
|
- Built-in port/connectivity health check (DNS, TCP 389, TCP 636, LDAP bind, LDAPS bind)
|
||||||
|
- Dry-run mode to validate before making changes
|
||||||
|
- Actions: enable, disable, add/remove from group, set/force password change
|
||||||
|
- LDAP 389 for normal edits; LDAPS 636 required for password resets
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
Pre-built binaries for each platform (no .NET SDK required):
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Windows x64 | `ADBulkTool-win-x64.exe` |
|
||||||
|
| Linux x64 | `ADBulkTool-linux-x64` |
|
||||||
|
| macOS x64 | `ADBulkTool-macos-x64` |
|
||||||
|
| macOS ARM | `ADBulkTool-macos-arm64` |
|
||||||
|
|
||||||
|
Grab the latest from [Releases](https://gitea.mikisoq.cc/mikisoq/ADBulk/releases/latest).
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||||
|
|
||||||
|
| Platform | Install |
|
||||||
|
|----------|---------|
|
||||||
|
| Windows | `winget install Microsoft.DotNet.SDK.8` |
|
||||||
|
| Arch-based | `sudo pacman -S dotnet-sdk-8.0` |
|
||||||
|
| Ubuntu / Debian | Register Microsoft repo then `sudo apt install dotnet-sdk-8.0` |
|
||||||
|
| Fedora / RHEL | `sudo dnf install dotnet-sdk-8.0` |
|
||||||
|
| macOS | `brew install dotnet-sdk-8.0` |
|
||||||
|
|
||||||
|
### Run directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.mikisoq.cc/mikisoq/ADBulk.git
|
||||||
|
cd ADBulk
|
||||||
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build standalone executables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All platforms at once (Linux/macOS)
|
||||||
|
./scripts/publish-all.sh
|
||||||
|
|
||||||
|
# All platforms at once (Windows PowerShell)
|
||||||
|
.\scripts\publish-all.ps1
|
||||||
|
|
||||||
|
# Single platform
|
||||||
|
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `publish/` — a single .exe/binary per platform, no runtime needed.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Launch the app
|
||||||
|
2. Click **Discover** (or let it auto-detect on startup)
|
||||||
|
3. Enter your AD bind username and password
|
||||||
|
4. Click **Test ports + LDAP/LDAPS** to verify connectivity
|
||||||
|
5. Load a CSV
|
||||||
|
6. Select the username column and an action
|
||||||
|
7. Run **Preview** then **Dry run** before executing
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- Test with a dedicated test OU and test accounts first
|
||||||
|
- Keep **Dry run** enabled until you are sure the mapping is correct
|
||||||
|
- Password reset actions require LDAPS and are blocked on plain LDAP
|
||||||
|
- Do not use **Ignore cert errors** in production
|
||||||
|
|
||||||
|
## LDAPS certificate trust
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch-based:
|
||||||
|
sudo cp your-root-ca.crt /etc/ca-certificates/trust-source/anchors/
|
||||||
|
sudo trust extract-compat
|
||||||
|
|
||||||
|
# Ubuntu / Debian:
|
||||||
|
sudo cp your-root-ca.crt /usr/local/share/ca-certificates/
|
||||||
|
sudo update-ca-certificates
|
||||||
|
|
||||||
|
# Fedora / RHEL:
|
||||||
|
sudo cp your-root-ca.crt /etc/pki/ca-trust/source/anchors/
|
||||||
|
sudo update-ca-trust
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
If the domain controller certificate is already trusted by the domain, nothing extra is needed. Otherwise, import the root CA into the **Trusted Root Certification Authorities** store.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using ADBulkTool.Models;
|
||||||
|
using DnsClient;
|
||||||
|
|
||||||
|
namespace ADBulkTool.Services;
|
||||||
|
|
||||||
|
public static class DomainDiscovery
|
||||||
|
{
|
||||||
|
public static async Task<DiscoveryResult> DiscoverAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var steps = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var domain = await GetSystemDomainAsync(timeout);
|
||||||
|
if (string.IsNullOrWhiteSpace(domain))
|
||||||
|
{
|
||||||
|
return new DiscoveryResult(null, null, null, null, null,
|
||||||
|
"Could not detect a DNS domain. Ensure the machine is on a domain network with a proper DNS configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.Add($"Detected domain: {domain}");
|
||||||
|
|
||||||
|
var searchBase = DomainToSearchBase(domain);
|
||||||
|
steps.Add($"Derived search base: {searchBase}");
|
||||||
|
|
||||||
|
var dnsServers = GetDnsServers();
|
||||||
|
var dnsServer = dnsServers.FirstOrDefault();
|
||||||
|
steps.Add(dnsServer is not null
|
||||||
|
? $"DNS server found: {dnsServer}"
|
||||||
|
: "No DNS server found, using system default resolver.");
|
||||||
|
|
||||||
|
var ldapHost = await FindDomainControllerViaSrvAsync(domain, dnsServer, timeout);
|
||||||
|
|
||||||
|
if (ldapHost is not null)
|
||||||
|
{
|
||||||
|
steps.Add($"Found DC via SRV record: {ldapHost}");
|
||||||
|
return new DiscoveryResult(domain, dnsServer, ldapHost, 636, searchBase,
|
||||||
|
string.Join(" | ", steps));
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapHost = await FindDomainControllerViaLdapSrvAsync(domain, dnsServer, timeout);
|
||||||
|
|
||||||
|
if (ldapHost is not null)
|
||||||
|
{
|
||||||
|
steps.Add($"Found DC via generic LDAP SRV: {ldapHost}");
|
||||||
|
return new DiscoveryResult(domain, dnsServer, ldapHost, 636, searchBase,
|
||||||
|
string.Join(" | ", steps));
|
||||||
|
}
|
||||||
|
|
||||||
|
var guessedHost = $"dc01.{domain}";
|
||||||
|
steps.Add($"SRV lookup failed. Guessed DC: {guessedHost}");
|
||||||
|
return new DiscoveryResult(domain, dnsServer, guessedHost, 636, searchBase,
|
||||||
|
string.Join(" | ", steps));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new DiscoveryResult(null, null, null, null, null,
|
||||||
|
$"Discovery failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> GetDnsServers()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
return GetDnsServersWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetDnsServersLinux();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? DomainToSearchBase(string? domain)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(domain))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = domain.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(",", parts.Select(p => $"DC={p}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> GetSystemDomainAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
var resolvDomain = await Task.Run(() => GetDomainFromResolvConf(), cts.Token);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvDomain))
|
||||||
|
{
|
||||||
|
return resolvDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
var winDomain = await Task.Run(() => GetDomainFromWindows(), cts.Token);
|
||||||
|
if (!string.IsNullOrWhiteSpace(winDomain))
|
||||||
|
{
|
||||||
|
return winDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostDomain = await Task.Run(() => GetDomainFromHostname(), cts.Token);
|
||||||
|
if (!string.IsNullOrWhiteSpace(hostDomain))
|
||||||
|
{
|
||||||
|
return hostDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipDomain = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return IPGlobalProperties.GetIPGlobalProperties().DomainName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}, cts.Token);
|
||||||
|
if (!string.IsNullOrWhiteSpace(ipDomain) && !string.Equals(ipDomain, "localdomain", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ipDomain.TrimEnd('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetDnsServersWindows()
|
||||||
|
{
|
||||||
|
var servers = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var props = ni.GetIPProperties();
|
||||||
|
if (props is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dns in props.DnsAddresses)
|
||||||
|
{
|
||||||
|
if (dns.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && !servers.Contains(dns.ToString()))
|
||||||
|
{
|
||||||
|
servers.Add(dns.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetDnsServersLinux()
|
||||||
|
{
|
||||||
|
var servers = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines("/etc/resolv.conf");
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith("nameserver ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length >= 2 && IPAddress.TryParse(parts[1], out _))
|
||||||
|
{
|
||||||
|
servers.Add(parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetDomainFromResolvConf()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines("/etc/resolv.conf");
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("domain ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length >= 2 && !string.IsNullOrWhiteSpace(parts[1]))
|
||||||
|
{
|
||||||
|
return parts[1].TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("search ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length >= 2 && !string.IsNullOrWhiteSpace(parts[1]))
|
||||||
|
{
|
||||||
|
return parts[1].TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetDomainFromWindows()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var props = ni.GetIPProperties();
|
||||||
|
if (props is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = props.DnsSuffix;
|
||||||
|
if (!string.IsNullOrWhiteSpace(suffix))
|
||||||
|
{
|
||||||
|
return suffix.TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CA1416 // Registry access is guarded by platform check
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
|
||||||
|
@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters");
|
||||||
|
if (key is not null)
|
||||||
|
{
|
||||||
|
var domain = key.GetValue("Domain") as string;
|
||||||
|
if (!string.IsNullOrWhiteSpace(domain))
|
||||||
|
{
|
||||||
|
return domain.TrimEnd('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var dhcpDomain = key.GetValue("DhcpDomain") as string;
|
||||||
|
if (!string.IsNullOrWhiteSpace(dhcpDomain))
|
||||||
|
{
|
||||||
|
return dhcpDomain.TrimEnd('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1416
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetDomainFromHostname()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hostname = Dns.GetHostEntry("localhost").HostName;
|
||||||
|
var dotIndex = hostname.IndexOf('.');
|
||||||
|
if (dotIndex > 0 && dotIndex < hostname.Length - 1)
|
||||||
|
{
|
||||||
|
var domain = hostname[(dotIndex + 1)..].TrimEnd('.');
|
||||||
|
return string.IsNullOrWhiteSpace(domain) ? null : domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullHostname = IPGlobalProperties.GetIPGlobalProperties().HostName;
|
||||||
|
var dotIndex = fullHostname.IndexOf('.');
|
||||||
|
if (dotIndex > 0 && dotIndex < fullHostname.Length - 1)
|
||||||
|
{
|
||||||
|
var domain = fullHostname[(dotIndex + 1)..].TrimEnd('.');
|
||||||
|
return string.IsNullOrWhiteSpace(domain) ? null : domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> FindDomainControllerViaSrvAsync(string domain, string? dnsServer, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var srvQuery = $"_ldap._tcp.dc._msdcs.{domain}";
|
||||||
|
var lookup = CreateLookup(dnsServer);
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
var result = await lookup.QueryAsync(srvQuery, QueryType.SRV, QueryClass.IN, cts.Token);
|
||||||
|
var record = result.Answers.SrvRecords().FirstOrDefault();
|
||||||
|
return record?.DomainName.Value.TrimEnd('.');
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> FindDomainControllerViaLdapSrvAsync(string domain, string? dnsServer, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var srvQuery = $"_ldap._tcp.{domain}";
|
||||||
|
var lookup = CreateLookup(dnsServer);
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
var result = await lookup.QueryAsync(srvQuery, QueryType.SRV, QueryClass.IN, cts.Token);
|
||||||
|
var record = result.Answers.SrvRecords().FirstOrDefault();
|
||||||
|
return record?.DomainName.Value.TrimEnd('.');
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LookupClient CreateLookup(string? dnsServer)
|
||||||
|
{
|
||||||
|
if (dnsServer is not null && IPAddress.TryParse(dnsServer, out var ip))
|
||||||
|
{
|
||||||
|
return new LookupClient(new LookupClientOptions(ip) { UseCache = false, Timeout = TimeSpan.FromSeconds(5) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LookupClient(new LookupClientOptions { UseCache = false, Timeout = TimeSpan.FromSeconds(5) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
param(
|
||||||
|
[string]$Version = "1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
$Project = "ADBulkTool.csproj"
|
||||||
|
$Config = "Release"
|
||||||
|
|
||||||
|
function Publish {
|
||||||
|
param([string]$Rid, [string]$Suffix)
|
||||||
|
|
||||||
|
Write-Host "Publishing for $Rid..."
|
||||||
|
dotnet publish $Project `
|
||||||
|
-c $Config `
|
||||||
|
-r $Rid `
|
||||||
|
--self-contained true `
|
||||||
|
-p:PublishSingleFile=true `
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||||
|
-p:Version=$Version `
|
||||||
|
-o "publish/$Suffix"
|
||||||
|
|
||||||
|
Write-Host " -> publish/$Suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location (Split-Path $PSScriptRoot)
|
||||||
|
|
||||||
|
Publish "win-x64" "windows-x64"
|
||||||
|
Publish "linux-x64" "linux-x64"
|
||||||
|
Publish "osx-x64" "macos-x64"
|
||||||
|
Publish "osx-arm64" "macos-arm64"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "All builds complete. Artifacts are in ./publish/"
|
||||||
|
Get-ChildItem ./publish/*/ | Select-Object Name, Length
|
||||||
|
|
||||||
|
Pop-Location
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="ADBulkTool.csproj"
|
||||||
|
CONFIG="Release"
|
||||||
|
VERSION="${1:-1.0.0}"
|
||||||
|
|
||||||
|
publish() {
|
||||||
|
local rid="$1"
|
||||||
|
local suffix="$2"
|
||||||
|
echo "Publishing for $rid..."
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
-c "$CONFIG" \
|
||||||
|
-r "$rid" \
|
||||||
|
--self-contained true \
|
||||||
|
-p:PublishSingleFile=true \
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
|
-p:Version="$VERSION" \
|
||||||
|
-o "publish/$suffix"
|
||||||
|
echo " -> publish/$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
publish "win-x64" "windows-x64"
|
||||||
|
publish "linux-x64" "linux-x64"
|
||||||
|
publish "osx-x64" "macos-x64"
|
||||||
|
publish "osx-arm64" "macos-arm64"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "All builds complete. Artifacts are in ./publish/"
|
||||||
|
ls -lh publish/*/
|
||||||
Reference in New Issue
Block a user