RDCMan is a godsend if you have to jump on multiple machines.  It's a wrapper for mstsc.exe, the venerable Remote Desktop Protocol client.  It allows you to create trees of server groups (though groups and servers cannot branch from the same node, be warned.)  You can even import lists of hostnames, but ...

But it drops them all into a single group.  You end up with RDG (remote desktop group) files that are a single flat tree with hundreds of servers.  Or, you import the computer names, manually create the groups, then drag-and-drop each node into the right group. As far as I can tell, there is no way to auto-create / auto-populate nested groups.

Here's a way to do it, but it relies on a few caveats:

  1. Your server names conform to a column-delineated naming scheme. 
  2. You can only have the server name once in the RDG file.
  3. You're willing to use .NET Regular Expressions with named matches.  (Don't worry.  It isn't as complicated as it might sound.)

Firstly, let's talk 'column-delineated naming schemes.'  "Peanuts characters" is a naming scheme, but not column-delineated.  For purposes of this example, we're going to assume that all server names consist of:

  • The building in which they're housed (4 characters, including leading 'B')
  • The lab number (2 digits)
  • The rack number (2 digits)
  • The unique number for the host (3 digits)

For human readability's sake, each field is further delimited by a hyphen, which brings up to 14-character hostnames, just one character shy of NT's limits.  Here's a sample list of names:

B107-01-01-048.test.lab.local
B107-01-01-049.test.lab.local
B107-01-01-050.test.lab.local
B107-01-01-051.test.lab.local

B107-02-10-001.test.lab.local
B107-02-10-002.test.lab.local
B107-02-10-003.test.lab.local
B107-02-10-004.test.lab.local

B107-02-11-007.test.lab.local
B107-02-11-008.test.lab.local
B107-02-11-009.test.lab.local
B107-02-11-010.test.lab.local

B120-01-01-023.test.lab.local
B120-01-01-024.test.lab.local
B120-01-01-025.test.lab.local
B120-01-01-026.test.lab.local

We're going to use the simplest of regular expression metacharacters here: '.'

A single, unadorned period in a regular expression means "any single character."  Two periods mean "any two characters."  So, the naming scheme above can be expressed as:

B...-..-..-...

However, we want to match against individual substrings as well as the overall string.  To define a submatch, enclose the regular expression for that submatch in (parentheses).  So, it turns into:

(B...)-(..)-(..)-(...)

Lastly, we want each submatch's meaning to be represented in the .RDG file.  This is where the 'named' part of 'named matches' comes in.  If we use the names 'Bldg', 'Lab', and 'Rack', then we have the following:

(?<Bldg>B...)-(?<Lab>..)-(?<Rack>..)

What happened to the last three characters?  Well, they're only part of the computer's name, but do not represent any group containing the computer, so they don't play a part in this.

That's all there is to it.


To run the script below:

Get-Content hostnames.txt | .\Set-Rdg.ps1 -Pattern '(?<Bldg>B...)-(?<Lab>..)-(?<Rack>..)'

From the above file, it generates the following:

BldgLabRack 


But, what if the labs were allocated teams, and all the 01 Labs belonged to one team?  (Yes, it's a reach, but we have one case that's a little harder to explain where SubString(4,3) of the computer name was the top-level group, and SubString(0,2) and (2,2) had to lump it.)

In this case, we use the -Taxonomy parameter to specify the ordering of the names.

Get-Content hostnames.txt | .\Set-Rdg.ps1 -Pattern '(?<Bldg>B...)-(?<Lab>..)-(?<Rack>..)' -Taxonomy Lab, Bldg, Rack

From the above file, we now get this:

LabBldgRack


A few of the other flags address specific issues.

 

-NameMap

In one case, we had a series of machines that were named with 'www' in the position indicating their function, and newer ones with 'web' in the same place.  Same function, two names.  The -NameMap allows the user to map one matching substring to another. 

-NameMap @{"www" = "WEB"}

PowerShell being case-insensitive, @{"WWW" = "WEB"} will work the same.

-DisplayNameRegex

In the sample file above, all the computer names are fully-qualified, but the tree view only shows the computer name, no domain.  The FQDN is stored in the Properties of each server, but the -DisplayNameRegex allows us to transform the FQDN into the name displayed.  The default value is '\..*', which means "lose everything after the first period in the FQDN."

-NoBackup

The script normally copies the existing .RDG file if it exists, but this parameter bypasses it.


The contents of the script are presented for viewing and reviewing, but do not copy-paste it.  This blog prefixes each line with a space, which normally means nothing to PowerShell, except in the case of:

@"
Here documents.
"@

It plays havoc with those.  Use the attachment, instead.

<#

Get-Content LabHosts.txt | Set-Rdg.ps1 -Pattern '(?<Bldg>B...)-(?<Lab>..)-(?<Rack>..)' 

===

Get-Content LabHosts.txt

B107-01-01-048.test.lab.local
B107-01-01-049.test.lab.local
B107-01-01-050.test.lab.local
B107-01-01-051.test.lab.local

B107-02-10-001.test.lab.local
B107-02-10-002.test.lab.local
B107-02-10-003.test.lab.local
B107-02-10-004.test.lab.local

B107-02-11-007.test.lab.local
B107-02-11-008.test.lab.local
B107-02-11-009.test.lab.local
B107-02-11-010.test.lab.local

B120-01-01-023.test.lab.local
B120-01-01-024.test.lab.local
B120-01-01-025.test.lab.local
B120-01-01-026.test.lab.local

#>

param(
     [Parameter(ValueFromPipeline = $true)][String[]]$ComputerName = @(),
     [string]$Path = "$home\Desktop\RDCman.rdg",
     [string]$UserName = $script:UserName,
     [string]$UserDnsDomain = $script:UserDnsDomain,
     [string]$RootNodeName = "RDCman",
     [string]$Pattern = '.*',
     [String[]]$Taxonomy = @(),
     [hashtable]$NameMap = $script:NameMap,
     [string]$DisplayNameRegex = '"\..*"',
     [switch]$NoBackup
);

begin {
     function Out-Error { 
         <#
         Output an error message without showing the stackdump, then terminate
         script unless -nobreak specified.
         #>
         param (
             [string]$message,       # message to display.
             [switch]$noBreak        # do not break out of script.
         );
         if (!$message) { $message = 'An unspecified error occured.'; }
         Write-Error -ErrorAction SilentlyContinue $message;

         # output more data if debugging or in verbose mode
         if (($DebugPreference -eq 'continue') -or ($VerbosePreference -eq 'continue')) {
             $function = (Get-Variable -scope 1 MyInvocation).Value.MyCommand.Name;
             if ($function) { $function += ":"; }
             if ($MyInvocation.ScriptName) {
                 $scriptName = (Split-Path -Path $MyInvocation.ScriptName -Leaf);
             } else {
                 $scriptName = $null;
             }
             $message = "(Line {0}) {1}:{2} $message" -f $MyInvocation.ScriptLineNumber, $scriptName, $function
         }

         Write-Host -ForegroundColor Red -BackgroundColor Black "ERROR: $message";
         if (!$noBreak) { break __outOfScript; }
     }

     function New-RdcManXml {
         #return an xml object with the root node of an RDG file.
         param (
             [string]$UserName = $script:UserName,
             [string]$UserDnsDomain = $script:UserDnsDomain,
             [string]$RootNodeName = "RDCman"
         );
        
         Write-Progress (Get-Date) "Creating RDG file." -Id 100;
@"
< ?xml version="1.0" encoding="utf-8"?>
< RDCMan schemaVersion="1">
    < version>2.2</version>
    < file>
        < properties>
            < name>$RootNodeName</name>
            < expanded>False</expanded>
            < comment />
            < logonCredentials inherit="None">
                < userName>$UserName</userName>
                < domain>$UserDnsDomain</domain>
                < password storeAsClearText="False" />
            < /logonCredentials>
            < connectionSettings inherit="FromParent" />
            < gatewaySettings inherit="FromParent" />
            < remoteDesktop inherit="FromParent" />
            < localResources inherit="None">
                < audioRedirection>0</audioRedirection>
                < audioRedirectionQuality>0</audioRedirectionQuality>
                < audioCaptureRedirection>0</audioCaptureRedirection>
                < keyboardHook>1</keyboardHook>
                < redirectClipboard>True</redirectClipboard>
                < redirectDrives>True</redirectDrives>
                < redirectPorts>True</redirectPorts>
                < redirectPrinters>True</redirectPrinters>
                < redirectSmartCards>True</redirectSmartCards>
            < /localResources>
            < securitySettings inherit="FromParent" />
            < displaySettings inherit="FromParent" />
        < /properties>
    < /file>
< /RDCMan>
"@ -as [xml];
     }

     function New-RdcManGroupInnerXml {
         #return the InnerText string for a <properties> XML element.
         param ( [string]$Name = $null );
        
         Write-Progress (Get-Date) "Creating $Name group element." -Id 200;
         if (!($Name)) { Out-Error 'New-RdcManGroupOuterText -name not specified'; }
@"
< properties>
    < name>$Name</name>
    < expanded>False</expanded>
    < comment />
    < logonCredentials inherit="FromParent" />
    < connectionSettings inherit="FromParent" />
    < gatewaySettings inherit="FromParent" />
    < remoteDesktop inherit="FromParent" />
    < localResources inherit="FromParent" />
    < securitySettings inherit="FromParent" />
    < displaySettings inherit="FromParent" />
< /properties>
"@
     }

     function New-RdcManServerInnerXml {
         #return the InnerText string for a <server> XML element.
         param (
             [string]$Name = $null,
             [string]$DisplayName = $null
         );

         Write-Progress (Get-Date) "Creating $Name ($DisplayName) server element." -Id 300;
         if (!($Name)) { Out-Error 'New-RdcManGroupOuterText -name not specified'; }
         if (!$DisplayName) { $DisplayName = $Dame; }
@"
< name>$Name</name>
< displayName>$displayName</displayName>
< comment />
< logonCredentials inherit="FromParent" />
< connectionSettings inherit="FromParent" />
< gatewaySettings inherit="FromParent" />
< remoteDesktop inherit="FromParent" />
< localResources inherit="FromParent" />
< securitySettings inherit="FromParent" />
< displaySettings inherit="FromParent" />
"@
     }

     function Import-ComputerName {
         #return a PSCustomObject with taxonomical entries as specified by -Pattern
         param ( 
             [Parameter(ValueFromPipeline = $true)][String[]]$ComputerName = @(),
             [string]$Pattern = '.*',
             [hashtable]$NameMap = @{},
             [switch]$NewTaxonomy
         );
        
         begin {
             if ($Pattern -notmatch '^\^') { $Pattern = "^$Pattern"; }
             if ($Pattern -notmatch '\$$') { $Pattern += '.*'; }
             if ($NewTaxonomy) { $script:Taxonomy = $null; }
         }
        
         process {
             foreach ($computer in $ComputerName) {
                 $computer = $computer -replace "\s";
                 if (!$computer) { continue; }
                 $computer = $computer.ToLower();
                 Write-Progress (Get-Date) "Parsing $computer server name." -Id 400;
                 Write-Debug "Import-ComputerName: '$computer'";
                 if ($computer -and ($computer.ToString().ToUpper() -match $Pattern)) {
                
                     if (!$script:Taxonomy) { 
                         $oldMatches = $matches;
                         if ($Pattern -match '\(\?<') {
                             $script:Taxonomy = ($Pattern -replace '>[^<]*<',',' -replace '^[^<]*<' -replace '>[^>]*').Split(','); 
                         } else {
                             $script:Taxonomy = @('Host', 'FQDN');
                         }
                         $matches = $oldMatches;
                     }
                    
                     if ([array]::IndexOf($script:Taxonomy, 'FQDN') -eq -1) { $script:Taxonomy += 'FQDN'; }
                     $object = New-Object -TypeName PsObject | Select-Object -Property $script:Taxonomy;
                     foreach ($property in $script:Taxonomy) {
                         if ($property -eq 'FQDN') {
                             $object.$property = $matches[0].ToLower();
                         } elseif ($matches.$property) {
                             if ($NameMap.($matches.$property)) {
                                 $object.$property = $NameMap.($matches.$property);
                             } else {
                                 $object.$property = $matches.$property;
                             }
                         }
                     }
                     $object;
                 } else {
                     Write-Debug "Computer '$computer' does not match '$Pattern'";
                 }
             }
         }
     }

     function Add-ComputerToRdg {
         param ( 
             [Parameter(ValueFromPipeline = $true)][String[]]$ComputerName = @(),
             [Xml]$rdgXml = $script:rdgXml,
             [string]$UserName = $script:UserName,
             [string]$UserDnsDomain = $script:UserDnsDomain,
             [string]$RootNodeName = $script:RootNodeName,
             [string]$Pattern = $script:Pattern,
             [hashtable]$NameMap = $script:NameMap,
             [string]$DisplayNameRegex = '"\..*"'
         );
        
         begin {

             #if no XML object passed in, create a new XML object
             if (!$rdgXml) {
                 $rdgXml = New-RdcManXml -userName $UserName -userDnsDomain $UserDnsDomain -rootNodeName $RootNodeName;
             }
            
             #list of computer-data objects
             $computers = @();
            
             #hash of computers already in XML.
             $existingComputerHash = @{};
             $rdgXml.SelectNodes("//server/name") | % { $existingComputerHash[$_.InnerText] = $true; }
            
    
         }
        
         process {
             #populate list of hosts to process
             foreach ($computer in $ComputerName) {
                 Write-Progress (Get-Date) "Inserting $computer server element." -Id 500;
                 $computers += Import-ComputerName -ComputerName $computer -Pattern $Pattern -NameMap $NameMap;
             }
         }
        
         end {
             foreach ($computer in ($computers | Sort-Object -Property $script:Taxonomy)) {
                 if ($existingComputerHash.($computer.FQDN)) {
                     Write-Debug "$($computer.FQDN) already found in RDG file";
                 } else {
                     $existingComputerHash.($computer.FQDN) = $true;
                    
                     #create taxonomy if required.
                     $xPaths = @();
                     foreach ($level in $script:Taxonomy) {
                         if ($level -ne 'FQDN') {
                             if ($xPaths.Count -eq 0) {
                                 $priorXPath = '/';
                                 $parentXPath = '//file';
                             } else {
                                 $priorXPath = $parentXPath = $xPaths[($xPaths.Count - 1)];
                             }
                             $xPath = "$priorXPath/group/properties/name[text()='$($computer.$level) $level']/../..";
                             if (!$rdgXml.SelectSingleNode($xPath)) {
                                 Write-Debug "Node xpath: $xPath";
                                 $element = $rdgXml.CreateElement('group');
                                 $element.InnerXml = New-RdcManGroupInnerXml -name "$($computer.$level) $level";
                                 $rdgXml.SelectSingleNode($parentXPath).AppendChild($element) | Out-Null;
                             }
                             $xPaths += $xPath;
                         } else {
                             Write-Debug "FQDN xpath: $xPath";
                         }
                     }
                    
                     #append host
                     $element = $rdgXml.CreateElement('server');
                     if ($DisplayNameRegex) { 
                         $DisplayName = Invoke-Expression "'$($computer.FQDN)' -replace $DisplayNameRegex";
                     } else {
                         $DisplayName = $computer.FQDN;
                     }
                     Write-Debug "Add-ComputerToRdg (DisplayName): $DisplayName (FQDN) $($computer.FQDN)";
                     $element.InnerXml = New-RdcManServerInnerXml -name $computer.FQDN -DisplayName $DisplayName;
                     $rdgXml.SelectSingleNode($xPath).AppendChild($element) | Out-Null
                 }
             }
             $script:rdgXml = $rdgXml;
             $rdgXml;
         }
     }

     if ($MyInvocation.invocationName -eq '.') { break __outOfScript; }

     $Taxonomy = $script:Taxonomy;

     if (!(Test-Path $Path)) {
         $NoBackup = $true;
         1 | Set-Content -Path $Path;
     }
    
     $Path = (Resolve-Path -Path $Path).ToString();
     $rdgXml = (Get-Content -Path $Path) -as [xml];
     if (!$NoBackup) {
         $backupPath = $Path -replace "(\.[^\.]*)$", "($((Get-Item -Path $path).LastWriteTime.ToString('yyyy-MM-dd_hh-mm')))`$1";
         Copy-Item $Path $backupPath -ErrorAction SilentlyContinue;
         if (Test-Path $backupPath) {
             Write-Host "Backed up: $backupPath";
         } else {
             Write-Warning "Failed to back up to $backupPath";
         }
     }    
}

process {
     Add-ComputerToRdg -ComputerName $ComputerName -DisplayNameRegex  $DisplayNameRegex | Out-Null;
}

end {
     Write-Debug ('$script:Taxonomy: ' + ([string]::Join(',',$script:Taxonomy)));
     $script:rdgXml.Save($Path);

     Write-Host "Updated: '$Path'";
}