Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ List of all contributors.
- EBWeist
- mdawsonuk
- labre_rdc
- Tyrix

## Translators / reviewers on Transifex

Expand Down
110 changes: 109 additions & 1 deletion Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion Source/NETworkManager.Localization/Resources/Strings.resx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Expand Down Expand Up @@ -4010,4 +4010,40 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
<data name="ToolTip_Reload" xml:space="preserve">
<value>Reload</value>
</data>
<data name="ActiveDirectoryImportFailed" xml:space="preserve">
<value>Active Directory import failed.</value>
</data>
<data name="ActiveDirectoryImportOptions" xml:space="preserve">
<value>Options</value>
</data>
<data name="ActiveDirectoryImportRequiresProfileFile" xml:space="preserve">
<value>Load or unlock a profile file before importing computers.</value>
</data>
<data name="ActiveDirectoryImportSummary" xml:space="preserve">
<value>Imported {0} computer profile(s). Skipped {1} duplicate name(s) in the target group. Skipped {2} without a DNS host name.</value>
</data>
<data name="ActiveDirectoryImportUsesCurrentCredentials" xml:space="preserve">
<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>
</data>
<data name="ActiveDirectorySearchBase" xml:space="preserve">
<value>Search base (OU DN)</value>
</data>
<data name="ActiveDirectorySearchBaseWatermark" xml:space="preserve">
<value>OU=Computers,DC=example,DC=com</value>
</data>
<data name="ExcludeDisabledComputerAccounts" xml:space="preserve">
<value>Exclude disabled computer accounts</value>
</data>
<data name="ImportComputersFromActiveDirectory" xml:space="preserve">
<value>Import computers from Active Directory</value>
</data>
<data name="ImportComputersFromActiveDirectoryDots" xml:space="preserve">
<value>Import computers from Active Directory...</value>
</data>
<data name="TargetProfileGroup" xml:space="preserve">
<value>Target profile group</value>
</data>
<data name="TargetProfileGroupWatermark" xml:space="preserve">
<value>Existing or new group name</value>
</data>
</root>
20 changes: 19 additions & 1 deletion Source/NETworkManager.Settings/SettingsInfo.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using DnsClient;
using DnsClient;
using Lextm.SharpSnmpLib.Messaging;
using NETworkManager.Controls;
using NETworkManager.Models;
Expand Down Expand Up @@ -2682,6 +2682,24 @@ public double RemoteDesktop_ProfileWidth
}
}

private string _remoteDesktop_ActiveDirectoryImportLdapSearchBase;

/// <summary>
/// Last LDAP search base (OU DN) used for Remote Desktop Active Directory computer import.
/// </summary>
public string RemoteDesktop_ActiveDirectoryImportLdapSearchBase
{
get => _remoteDesktop_ActiveDirectoryImportLdapSearchBase;
set
{
if (value == _remoteDesktop_ActiveDirectoryImportLdapSearchBase)
return;

_remoteDesktop_ActiveDirectoryImportLdapSearchBase = value;
OnPropertyChanged();
}
}

#endregion

#region PowerShell
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace NETworkManager.Utilities.ActiveDirectory;

/// <summary>
/// Represents a computer account returned from Active Directory LDAP search.
/// </summary>
/// <param name="ProfileName">Display name for the profile (typically sAMAccountName without trailing '$').</param>
/// <param name="DnsHostName">DNS host name used for RDP when present.</param>
public readonly record struct ActiveDirectoryComputerRecord(string ProfileName, string DnsHostName);
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices;
using System.Runtime.InteropServices;

namespace NETworkManager.Utilities.ActiveDirectory;

/// <summary>
/// Queries Active Directory for computer accounts under a search base, including subtrees.
/// Uses the current Windows identity to bind to the directory.
/// </summary>
public static class ActiveDirectoryComputerSearcher
{
private const int LdapPageSize = 500;

/// <summary>
/// Returns computer accounts under <paramref name="ldapSearchRoot"/> with subtree scope.
/// </summary>
/// <param name="ldapSearchRoot">Distinguished name or LDAP path (with or without LDAP:// prefix).</param>
/// <param name="excludeDisabledComputerAccounts">When true, computer accounts with ACCOUNTDISABLE are omitted.</param>
/// <returns>Sorted list by profile name.</returns>
/// <exception cref="ArgumentException">When <paramref name="ldapSearchRoot"/> is null or whitespace.</exception>
/// <exception cref="InvalidOperationException">When the directory search fails.</exception>
public static IReadOnlyList<ActiveDirectoryComputerRecord> GetComputersInSubtree(
string ldapSearchRoot,
bool excludeDisabledComputerAccounts)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ldapSearchRoot);

var ldapPath = NormalizeLdapPath(ldapSearchRoot.Trim());

var ldapFilter = excludeDisabledComputerAccounts
? "(&(&(objectCategory=computer)(objectClass=computer))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
: "(&(objectCategory=computer)(objectClass=computer))";

try
{
using var directoryEntry = new DirectoryEntry(ldapPath);
using var directorySearcher = new DirectorySearcher(directoryEntry)
{
SearchScope = SearchScope.Subtree,
Filter = ldapFilter,
PageSize = LdapPageSize,
Tombstone = false
};

directorySearcher.PropertiesToLoad.Add("dnsHostName");
directorySearcher.PropertiesToLoad.Add("name");
directorySearcher.PropertiesToLoad.Add("sAMAccountName");

var computers = new List<ActiveDirectoryComputerRecord>();

using var searchResults = directorySearcher.FindAll();
foreach (SearchResult searchResult in searchResults)
{
var dnsHostName = GetFirstPropertyString(searchResult, "dnsHostName");
var nameAttribute = GetFirstPropertyString(searchResult, "name");
var samAccountName = GetFirstPropertyString(searchResult, "sAMAccountName");

var profileName = !string.IsNullOrEmpty(samAccountName)
? samAccountName.TrimEnd('$')
: nameAttribute;

if (string.IsNullOrWhiteSpace(profileName))
profileName = nameAttribute;

if (string.IsNullOrWhiteSpace(profileName))
continue;

computers.Add(new ActiveDirectoryComputerRecord(profileName.Trim(), dnsHostName ?? string.Empty));
}

computers.Sort((left, right) =>
string.Compare(left.ProfileName, right.ProfileName, StringComparison.OrdinalIgnoreCase));

return computers;
}
catch (COMException exception)
{
throw new InvalidOperationException(
"Active Directory search failed. Verify the search base, permissions, and domain connectivity.",
exception);
}
}

private static string NormalizeLdapPath(string input)
{
if (input.StartsWith("LDAP://", StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("LDAPS://", StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("GC://", StringComparison.OrdinalIgnoreCase))
return input;

return "LDAP://" + input;
}

private static string GetFirstPropertyString(SearchResult searchResult, string propertyName)
{
if (!searchResult.Properties.Contains(propertyName) || searchResult.Properties[propertyName].Count == 0)
return string.Empty;

return searchResult.Properties[propertyName][0]?.ToString() ?? string.Empty;
}
}
5 changes: 4 additions & 1 deletion Source/NETworkManager.Validators/GroupNameValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Windows.Controls;
using NETworkManager.Localization.Resources;

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

if (string.IsNullOrEmpty(groupName))
return ValidationResult.ValidResult;

if (groupName.StartsWith("~"))
return new ValidationResult(false,
string.Format(Strings.GroupNameCannotStartWithX, "~"));
Expand Down
28 changes: 27 additions & 1 deletion Source/NETworkManager/ProfileDialogManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using MahApps.Metro.SimpleChildWindow;
using MahApps.Metro.SimpleChildWindow;
using NETworkManager.Controls;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
Expand Down Expand Up @@ -567,6 +567,32 @@ public static async Task ShowDeleteProfileDialog(Window parentWindow, IProfileMa
ProfileManager.RemoveProfiles(profiles);
}

public static Task ShowImportComputersFromActiveDirectoryDialog(Window parentWindow,
IProfileManagerMinimal viewModel, string suggestedTargetGroup)
{
var childWindow = new ImportAdComputersChildWindow(parentWindow);

void CloseChild()
{
childWindow.IsOpen = false;
Settings.ConfigurationManager.Current.IsChildWindowOpen = false;

viewModel.OnProfileManagerDialogClose();
}

var childWindowViewModel =
new ImportAdComputersViewModel(parentWindow, suggestedTargetGroup ?? string.Empty, CloseChild);

childWindow.Title = Strings.ImportComputersFromActiveDirectory;
childWindow.DataContext = childWindowViewModel;

viewModel.OnProfileManagerDialogOpen();

Settings.ConfigurationManager.Current.IsChildWindowOpen = true;

return parentWindow.ShowChildWindowAsync(childWindow);
}

#endregion

#region Dialog to add, edit and delete group
Expand Down
Loading