Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
118 changes: 113 additions & 5 deletions Source/NETworkManager.Localization/Resources/Strings.Designer.cs

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

37 changes: 36 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 @@ -4019,6 +4019,41 @@ 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>
Comment thread
BornToBeRoot marked this conversation as resolved.
<data name="Firewall" xml:space="preserve">
<value>Firewall</value>
</data>
Expand Down
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 @@ -2338,6 +2338,24 @@ public double RemoteDesktop_ProfileWidth
}
} = GlobalStaticConfiguration.Profile_DefaultWidthExpanded;

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
Loading
Loading