Skip to content

Commit 6cdec70

Browse files
committed
Feature (RDP): Active Directory computer import (OU subtree, flat hierarchy)
1 parent 022ed19 commit 6cdec70

11 files changed

Lines changed: 708 additions & 5 deletions

File tree

Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Lines changed: 109 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Source/NETworkManager.Localization/Resources/Strings.resx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<root>
33
<!--
44
Microsoft ResX Schema
@@ -4010,4 +4010,40 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
40104010
<data name="ToolTip_Reload" xml:space="preserve">
40114011
<value>Reload</value>
40124012
</data>
4013+
<data name="ActiveDirectoryImportFailed" xml:space="preserve">
4014+
<value>Active Directory import failed.</value>
4015+
</data>
4016+
<data name="ActiveDirectoryImportOptions" xml:space="preserve">
4017+
<value>Options</value>
4018+
</data>
4019+
<data name="ActiveDirectoryImportRequiresProfileFile" xml:space="preserve">
4020+
<value>Load or unlock a profile file before importing computers.</value>
4021+
</data>
4022+
<data name="ActiveDirectoryImportSummary" xml:space="preserve">
4023+
<value>Imported {0} computer profile(s). Skipped {1} duplicate name(s) in the target group. Skipped {2} without a DNS host name.</value>
4024+
</data>
4025+
<data name="ActiveDirectoryImportUsesCurrentCredentials" xml:space="preserve">
4026+
<value>Uses your current Windows credentials to read from Active Directory. The account must be allowed to enumerate computer objects under the search base (subtree).</value>
4027+
</data>
4028+
<data name="ActiveDirectorySearchBase" xml:space="preserve">
4029+
<value>Search base (OU DN)</value>
4030+
</data>
4031+
<data name="ActiveDirectorySearchBaseWatermark" xml:space="preserve">
4032+
<value>OU=Computers,DC=example,DC=com</value>
4033+
</data>
4034+
<data name="ExcludeDisabledComputerAccounts" xml:space="preserve">
4035+
<value>Exclude disabled computer accounts</value>
4036+
</data>
4037+
<data name="ImportComputersFromActiveDirectory" xml:space="preserve">
4038+
<value>Import computers from Active Directory</value>
4039+
</data>
4040+
<data name="ImportComputersFromActiveDirectoryDots" xml:space="preserve">
4041+
<value>Import computers from Active Directory...</value>
4042+
</data>
4043+
<data name="TargetProfileGroup" xml:space="preserve">
4044+
<value>Target profile group</value>
4045+
</data>
4046+
<data name="TargetProfileGroupWatermark" xml:space="preserve">
4047+
<value>Existing or new group name</value>
4048+
</data>
40134049
</root>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace NETworkManager.Utilities.ActiveDirectory;
2+
3+
/// <summary>
4+
/// Represents a computer account returned from Active Directory LDAP search.
5+
/// </summary>
6+
/// <param name="ProfileName">Display name for the profile (typically sAMAccountName without trailing '$').</param>
7+
/// <param name="DnsHostName">DNS host name used for RDP when present.</param>
8+
public readonly record struct ActiveDirectoryComputerRecord(string ProfileName, string DnsHostName);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.DirectoryServices;
4+
using System.Runtime.InteropServices;
5+
6+
namespace NETworkManager.Utilities.ActiveDirectory;
7+
8+
/// <summary>
9+
/// Queries Active Directory for computer accounts under a search base, including subtrees.
10+
/// Uses the current Windows identity to bind to the directory.
11+
/// </summary>
12+
public static class ActiveDirectoryComputerSearcher
13+
{
14+
private const int LdapPageSize = 500;
15+
16+
/// <summary>
17+
/// Returns computer accounts under <paramref name="ldapSearchRoot"/> with subtree scope.
18+
/// </summary>
19+
/// <param name="ldapSearchRoot">Distinguished name or LDAP path (with or without LDAP:// prefix).</param>
20+
/// <param name="excludeDisabledComputerAccounts">When true, computer accounts with ACCOUNTDISABLE are omitted.</param>
21+
/// <returns>Sorted list by profile name.</returns>
22+
/// <exception cref="ArgumentException">When <paramref name="ldapSearchRoot"/> is null or whitespace.</exception>
23+
/// <exception cref="InvalidOperationException">When the directory search fails.</exception>
24+
public static IReadOnlyList<ActiveDirectoryComputerRecord> GetComputersInSubtree(
25+
string ldapSearchRoot,
26+
bool excludeDisabledComputerAccounts)
27+
{
28+
ArgumentException.ThrowIfNullOrWhiteSpace(ldapSearchRoot);
29+
30+
var ldapPath = NormalizeLdapPath(ldapSearchRoot.Trim());
31+
32+
var ldapFilter = excludeDisabledComputerAccounts
33+
? "(&(&(objectCategory=computer)(objectClass=computer))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
34+
: "(&(objectCategory=computer)(objectClass=computer))";
35+
36+
try
37+
{
38+
using var directoryEntry = new DirectoryEntry(ldapPath);
39+
using var directorySearcher = new DirectorySearcher(directoryEntry)
40+
{
41+
SearchScope = SearchScope.Subtree,
42+
Filter = ldapFilter,
43+
PageSize = LdapPageSize,
44+
Tombstone = false
45+
};
46+
47+
directorySearcher.PropertiesToLoad.Add("dnsHostName");
48+
directorySearcher.PropertiesToLoad.Add("name");
49+
directorySearcher.PropertiesToLoad.Add("sAMAccountName");
50+
51+
var computers = new List<ActiveDirectoryComputerRecord>();
52+
53+
using var searchResults = directorySearcher.FindAll();
54+
foreach (SearchResult searchResult in searchResults)
55+
{
56+
var dnsHostName = GetFirstPropertyString(searchResult, "dnsHostName");
57+
var nameAttribute = GetFirstPropertyString(searchResult, "name");
58+
var samAccountName = GetFirstPropertyString(searchResult, "sAMAccountName");
59+
60+
var profileName = !string.IsNullOrEmpty(samAccountName)
61+
? samAccountName.TrimEnd('$')
62+
: nameAttribute;
63+
64+
if (string.IsNullOrWhiteSpace(profileName))
65+
profileName = nameAttribute;
66+
67+
if (string.IsNullOrWhiteSpace(profileName))
68+
continue;
69+
70+
computers.Add(new ActiveDirectoryComputerRecord(profileName.Trim(), dnsHostName ?? string.Empty));
71+
}
72+
73+
computers.Sort((left, right) =>
74+
string.Compare(left.ProfileName, right.ProfileName, StringComparison.OrdinalIgnoreCase));
75+
76+
return computers;
77+
}
78+
catch (COMException exception)
79+
{
80+
throw new InvalidOperationException(
81+
"Active Directory search failed. Verify the search base, permissions, and domain connectivity.",
82+
exception);
83+
}
84+
}
85+
86+
private static string NormalizeLdapPath(string input)
87+
{
88+
if (input.StartsWith("LDAP://", StringComparison.OrdinalIgnoreCase) ||
89+
input.StartsWith("LDAPS://", StringComparison.OrdinalIgnoreCase) ||
90+
input.StartsWith("GC://", StringComparison.OrdinalIgnoreCase))
91+
return input;
92+
93+
return "LDAP://" + input;
94+
}
95+
96+
private static string GetFirstPropertyString(SearchResult searchResult, string propertyName)
97+
{
98+
if (!searchResult.Properties.Contains(propertyName) || searchResult.Properties[propertyName].Count == 0)
99+
return string.Empty;
100+
101+
return searchResult.Properties[propertyName][0]?.ToString() ?? string.Empty;
102+
}
103+
}

Source/NETworkManager.Validators/GroupNameValidator.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Globalization;
1+
using System.Globalization;
22
using System.Windows.Controls;
33
using NETworkManager.Localization.Resources;
44

@@ -10,6 +10,9 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
1010
{
1111
var groupName = value as string;
1212

13+
if (string.IsNullOrEmpty(groupName))
14+
return ValidationResult.ValidResult;
15+
1316
if (groupName.StartsWith("~"))
1417
return new ValidationResult(false,
1518
string.Format(Strings.GroupNameCannotStartWithX, "~"));

Source/NETworkManager/ProfileDialogManager.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using MahApps.Metro.SimpleChildWindow;
1+
using MahApps.Metro.SimpleChildWindow;
22
using NETworkManager.Controls;
33
using NETworkManager.Localization.Resources;
44
using NETworkManager.Models;
@@ -567,6 +567,32 @@ public static async Task ShowDeleteProfileDialog(Window parentWindow, IProfileMa
567567
ProfileManager.RemoveProfiles(profiles);
568568
}
569569

570+
public static Task ShowImportComputersFromActiveDirectoryDialog(Window parentWindow,
571+
IProfileManagerMinimal viewModel, string suggestedTargetGroup)
572+
{
573+
var childWindow = new ImportAdComputersChildWindow(parentWindow);
574+
575+
void CloseChild()
576+
{
577+
childWindow.IsOpen = false;
578+
Settings.ConfigurationManager.Current.IsChildWindowOpen = false;
579+
580+
viewModel.OnProfileManagerDialogClose();
581+
}
582+
583+
var childWindowViewModel =
584+
new ImportAdComputersViewModel(parentWindow, suggestedTargetGroup ?? string.Empty, CloseChild);
585+
586+
childWindow.Title = Strings.ImportComputersFromActiveDirectory;
587+
childWindow.DataContext = childWindowViewModel;
588+
589+
viewModel.OnProfileManagerDialogOpen();
590+
591+
Settings.ConfigurationManager.Current.IsChildWindowOpen = true;
592+
593+
return parentWindow.ShowChildWindowAsync(childWindow);
594+
}
595+
570596
#endregion
571597

572598
#region Dialog to add, edit and delete group

0 commit comments

Comments
 (0)