Monday, October 31, 2022

Notes About PowerShell: Adding a TreeView to the GUI - Part 3

In Notes About PowerShell: Adding a TreeView to the GUI - Part 2 of this series, we had a complete set of four PowerShell scripts that together creates a GUI window that displays a Windows Forms TreeView, allowing the user to select a family member from a small family tree. In this post, we're going to replace that family tree with the computer's file system.

Let's make one quick modification to tinker.ps1 file so that our results are graphically displayed in addition to textually in the console. Add in the bolded code below:

   ...
   
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
    [System.Windows.Forms.MessageBox]::Show("Results:`n`nNODE:`n `"$($Node.Text)`"`n`nPATH TO NODE:`n `"$($NodePath.Text)`"")

   ...

Give 'er a test spin.

Okay, on to putting the filesystem into a treeview. First, we'll need to have the drive letters of Windows. For experimentation/learning purposes, at a PowerShell prompt (not in your script), enter the following command, and you'll see results similar to the following:

PS C:\Users\acutech> Get-PSDrive

Name           Used (GB)     Free (GB) Provider      Root                                CurrentLocation
----           ---------     --------- --------      ----                                ---------------
Alias                                  Alias                                                                                                                                                                                     
C                  24.30         55.00 FileSystem    C:\                                 Users\acutech
Cert                                   Certificate   \
D                                      FileSystem    D:\                                                                                                                                                                         
Env                                    Environment                                                                                                                                                                               
Function                               Function                                                                                                                                                                                 
HKCU                                   Registry      HKEY_CURRENT_USER                                                                                                                                                           
HKLM                                   Registry      HKEY_LOCAL_MACHINE                                                                                                                                                         
Variable                               Variable                                                                                                                                                                                 
WSMan                                  WSMan                                                                                                                                                                                    


PS C:\Users\acutech> 

We're only interested in the filesystem drive letters, not the registry keys or certificates or etc. So let's put some limitations on the command:

PS C:\Users\acutech> Get-PSDrive -PSProvider FileSystem

Name           Used (GB)     Free (GB) Provider      Root                                CurrentLocation
----           ---------     --------- --------      ----                                ---------------
C                  24.30         55.00 FileSystem    C:\                                 Users\acutech
D                                      FileSystem    D:\


PS C:\Users\acutech>

Better. But all we really want is the drive letter itself:

PS C:\Users\acutech> (Get-PSDrive -PSProvider FileSystem).Root
C:\
D:\

PS C:\Users\acutech> (Get-PSDrive -PSProvider FileSystem).Name
C
D

Great! Both of these commands give us an array containing the filesystem drives. Let's load them up into the treeview.

Let's start with a reminder of our tinker_add_nodes.ps1 file:

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

Delete all of that code; we're done with it. And replace it with this code:

$Drives = (Get-PSDrive -PSProvider FileSystem).Root        # Get the drive letters in an array named $Drives
foreach ($_ in $Drives) {                                  # For each drive letter in the array,
    $TreeView.Nodes.Add($_)                                #   add the drive letter to the treeview.
}

Open up .tinker.ps1 if you haven't already done so, and run it. You should get something like this:

This Document is Under Construction

Friday, October 28, 2022

Notes About PowerShell: Adding a TreeView to the GUI - Part 2

See Part 1 here.

If you just want to see the code, without reading how we get there, the code listings are at the bottom of this page.

In Part 1, we built a basic Windows form with a blank treeview. Now we're going to populate the treeview. Probably the three most popular "databases" that are looked at in PowerShell in a tree form are the file system, the Windows Registry, and Active Directory domains. I plan to look at each of these three sources via a treeview, but let's start with something simpler, a simple family tree.

As you remember from Part 1, most of our GUI-window construction takes place in an external file, "tinker_GUI.ps1", which is dot-sourced from our primary script file, "tinker.ps1". That primary script file currently looks like this:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

If you've followed along with Part 1, you should be able to run the above script, and see a window with an empty treeview, a box for entering your name, and an OK button.

Since a treeview looks at data that is in a tree-like form, we're probably less interested in the user's name and more interested in the leaf-node endpoint of interest in our tree, and the path it takes to get to that leaf-node. So let's edit the second, external script file, so that it replaces the "Name" box with a "Node" box. Let's also take out the pre-fill of that box, and the activation/selection/focus of that box.

Use your PowerShell ISE to open the "tinker_GUI.ps1" script file, and make the following modifications, deleting the strike-out text and adding in the bolded text. You'll find it easier to change "NameBox" to "Node" by using the PowerShell ISE's Edit / Replace in Script... feature to replace all instances of "NameBox" with "Node".

     ...
     
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# A textbox.The node.
$NameBoxNode = New-Object "System.Windows.Forms.Textbox"
$NameBoxNode.Size = "175,25"
$NameBoxNode.Location = "290,40"
$NameBox.Text = "Babushka"                               # Pre-fill the box.
$Win.Controls.Add($NameBoxNode)
$Win.ActiveControl = $NameBox                            # Once form shows, select this box.
  # Put a label with the box.
$Label_NameBoxNode = New-Object 'System.Windows.Forms.Label'
$Label_NameBoxNode.Text = "Enter your nameSelected Node:"
$Label_NameBoxNode.Size = "150, 25"
$Label_NameBoxNode.Location = '290, 20'
$Win.Controls.Add($Label_NameBoxNode)

   ...

Try running your script, to make sure it all works as expected.

We're also interested in the path to that node, so let's add a "Path to Node" box (by adding the bolded text in the indicated place).

     ...
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView

   ...

Now, for our family tree.

Imagine two brothers, John and William. John has three kids, Mary, Fred, and Alvin, and Fred has one, Delbert. William has four, Estelle, Angus, Eugene, and Marvin. Let's make a tree that views these relationships, and put that tree into the treeview object on our GUI form.

Ideally, since adding things to the treeview is conceptually different than actually building the window and the node and path and treeview box forms, we'd put the coding instructions to add things to the treeview somewhere else besides in this file. However, once the form is shown on-screen (with the $Win.ShowDialog(), no code below that line is executed until that window form is closed.

So, our options are to embed the code in the midst of all this other code, or to put the code to an external file and then import it as a dot-sourced file into a location just above the $Win.ShowDialog() line, or to put the code into a function and call that function just prior to the $Win.ShowDialog() line.

Just for sake of example, let's add a couple of nodes, the two patriarch brothers, with the code embedded in the tinker_GUI.ps1 file:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

# Display the form
$Win.ShowDialog()

   ...

Run the script, and you should start seeing the treeview come together.

Now, instead of embedding this code in the tinker_GUI.ps1 file, let's move that code out of it, and into a new file we can name tinker_add_nodes.ps1. So tinker_GUI.ps1 becomes:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form
$Win.ShowDialog()

   ...

and tinker_add_nodes.ps1 becomes:

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

Running tinker.ps1 should still produce the output you expect.

Now let's add the kids and grandkid.

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")

As you can see, the .Nodes values are arrays, that attach behind each other like the railroad cars of a train. The first level array, $Win.Nodes has two elements: .Nodes[0] contains the name "John". So by adding "Mary" and "Fred" as new element nodes to $Win.Node[0], "Mary" becomes $Win.Nodes[0].Nodes[0] and shows up in the treeview as "John's" daughter, as does "Fred" as $Win.Nodes[0].Nodes[1]. Don't worry too much if this doesn't yet make sense to you; it should start making more sense the farther we go and the more you work with it.

To help visualize this, we can add this code:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
  ...

You'll see that the tree has been expanded out to Delbert, without you having to expand anything using your mouse. If you press TAB a couple of times, until the focus lands on the treeview box, you'll see "Delbert" is highlighted.

We can even move the TAB focus to the treeview programatically:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
  ...

You may recall we used the "$Win.ActiveControl" setting earlier, in the definitions for the "Node" box, but then deleted that. If it had not been deleted, this second instance would simpy overwrite the effects of the first instance, because it comes later in the code.

But speaking of the "Node" box, this'd be a great time to fill it with the selected node:

...
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

You might think that since this is a property of $Node, it should go with all the other property settings in the section of code where we first defined $Node. That's great thinking. Unfortunately, since PowerShell is an interpreted language rather than a compiled language, PowerShell doesn't know about the $TreeView.SelectedNode.Text until after it reads this section of code, long after it has passed that section of code. So this assignment needs to go here instead of there.

This would also be a good time to report the selected node after the OK button is pressed. Remember, this code is in the main file, tinker.ps1.

  ...
# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The namenode in the box is `"$($NameBoxNode.Text)`".")
} elseif If ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the namenode that is in the box.")
} # end of If
  

If you click around in the treeview, say, by clicking on "Mary", you'll see the focus follows your click. But nothing else happens.

In order to perform some action when we click in the treeview, we'll need more code. In the definition for the treeview, we'll add this line:

  ...
$TreeView.Size = "240,400"
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$Win.Controls.Add($TreeView)
  ...

All this does is print "Ouch" to the PowerShell console. (You could print it to a pop-up message box with a slightly more-complicated statement - [System.Windows.Forms.MessageBox]::Show("Ouch"). (The brackets are kind of a short-hand way of creating an object on-the-fly, without the whole "$MessageBox = New-Object..." variable declaration thingy, but since this is a run-time "declaration", the dot, ".", becomes "::" (among other differences).))

Each time you click anywhere in the treeview area, you'll get an "Ouch".

However, what if we want to do more than a single command on a mouse-click?

We could continue adding code in this embedded manner, like so:

$treeview.add_NodeMouseClick(
  {
    Write-Out("Ouch")})
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
  }
)

Or we could use a variable here that functions like a function by standing in for a whole section of code, or use an actual function call. To keep the various code pieces separate as much as we can, and keep one section of code uncluttered by another portion of code, let's put the variable/function in another file, say, tinker_node_selected.ps1.

$TreeViewMouseClickEvent = {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
}

And we'll have to make two changes to tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"
 ...
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$treeview.add_NodeMouseClick($TreeViewMouseClickEvent)

Or we can create a function, and call that function:

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
} # end of TreeViewMouseClickEvent function
and
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})

Notice that with the embedded and function methods, curly braces are needed, but not with the variable method. Notice also that the function method is not preceded by the $ sign, whereas the variable method is. Lastly, note that the variable declaration includes an = sign, whereas the function declaration does not.

For a fuller look at these three methods, see here. We'll continue on using the function method.

If you watch the console as you click around in the treeview, you'll notice that the name that appears in the console is the name you leave, rather than the name on which you click. That's not the behavior we want. The reason this happens is because the PowerShell scripting engine runs the function/variable/embedded code before changing the treeview's selected node. We don't want to process the name we just left, but rather the name we just clicked on. So we'll have to change our function, to use arguments that the Add_NodeMouseClick feature automatically provides to us:

   ...
  
Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
Write-Host("You just clicked on $($_.Node.Text).")

   ...

The $_ represents the un-named variable given to our function which holds the arguments from the Add_NodeMouseClick event.

Those console messages aren't very valuable to us, though. Let's instead use this function to change out the text of the two text boxes on our form.

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked on $($_.Node.Text).")
    Write-Host("Stop it! That hurts!")
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

This almost works like we want, except you'll notice that if you click on a plus or minus in the treeview, the "Node:" and "Path:" fields change, even if we haven't actually selected a different node. Thinking about it, that makes sense; the Mouse-click event is raised when we click the mouse in the treeview area. We just need to use a different event. Easy. We'll also change the name of our function, to make it more accurate as to what is going on.

In tinker_node_selected.ps1:

     ...

function TreeViewMouseClickEvent {
function TreeNodeSelected {
   ...   
} # end of TreeViewMouseClickEvent function
} # end of TreeNodeSelected function

And in tinker_GUI.ps1:

   ...
  
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})
$treeview.add_AfterSelect({TreeNodeSelected})
  
  ...

Now we have a working form with a working treeview that allows us to read the selected node and its path. Let's change our output to include both of these bits of data. In tinker.ps1:

   ...
   
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The node in the box is `"$($Node.Text)`".")

If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
    
    ...

Just for clarity, here are the four files we are using, in their entirety:

tinker.ps1:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the node that is in the box.")
} # end of If

tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"

# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
$Win.ClientSize = '490,460'                              # Size of window frame.
$Win.text = "My Parent Window"                           # Title of the Win frame.
$Win.BackColor = "#ffffff"                               # Background color of the Win frame.

# Create an OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'     # Create the button.
$buttonOK.Text = "OK"                                    # Puts text on the button.
$buttonOK.Location = '350, 240'
$buttonOK.Size = '60, 25'
$buttonOK.Anchor = 'Bottom, Right'                       # Keep button in relative bottom-right of Win.
$buttonOK.DialogResult = "OK"                            # "None|OK|Cancel|Abort|Retry|Ignore|Yes|No".
$Win.Controls.Add($buttonOK)                             # Add the button to the form.
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# The node.
$Node = New-Object "System.Windows.Forms.Textbox"
$Node.Size = "175,25"
$Node.Location = "290,40"
$Win.Controls.Add($Node)
  # Put a label with the box.
$Label_Node = New-Object 'System.Windows.Forms.Label'
$Label_Node.Text = "Selected Node:"
$Label_Node.Size = "150, 25"
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView
$TreeView.Location = "10,40"
$TreeView.Size = "250,400"
$treeview.add_AfterSelect({TreeNodeSelected})
$Win.Controls.Add($TreeView)                             # Add the tree view to the main window form.
  # Put a label with the object.
$Label_TreeView = New-Object System.Windows.Forms.Label
$Label_TreeView.Location = "10,20"
$Label_TreeView.Size = "150,25"
$Label_TreeView.Text = "My Tree Object:"
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form.
$Win.ShowDialog() 

tinker_add_nodes.ps1:

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

tinker_node_selected.ps1:

function TreeNodeSelected {
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

Next up, in Part 3, we'll do this using the file system.

Notes About PowerShell: Adding a TreeView to the GUI, Part 1

Previously we built the Simplest PowerShell GUI, and then added a few features to it here.

The script below is that last more complex GUI (with a few edits). It presents a window frame, with an OK button object, and a textbox object that asks for a name to be entered. We'll build on this script to add a treeview object. A treeview allows you to browse a tree-like structure, such as the file system, or an Active Directory domain, or the Windows Registry. So fire up the ol' PowerShell ISE, copy and paste in the following code (making sure the quotation marks don't turn into "smart" quotation marks), and then save the file as something like "tinker.ps1". Then try running it to make sure it works.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
$Win.ClientSize = '490,460'                              # Size of window frame.
$Win.text = "My Parent Window"                           # Title of the Win frame.
$Win.BackColor = "#ffffff"                               # Background color of the Win frame.

# Create an OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'     # Create the button.
$buttonOK.Text = "OK"                                    # Puts text on the button.
$buttonOK.Location = '350, 240'
$buttonOK.Size = '60, 25'
$buttonOK.Anchor = 'Bottom, Right'                       # Keep button in relative bottom-right of Win.
$buttonOK.DialogResult = "OK"                            # "None|OK|Cancel|Abort|Retry|Ignore|Yes|No".
$Win.Controls.Add($buttonOK)                             # Add the button to the form.
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# A textbox.
$NameBox = New-Object "System.Windows.Forms.Textbox"
$NameBox.Size = "175,25"
$NameBox.Location = "290,40"
$NameBox.Text = "Babushka"                               # Pre-fill the box.
$Win.Controls.Add($NameBox)
$Win.ActiveControl = $NameBox                            # Once form shows, select this box.
  # Put a label with the box.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "150, 25"
$Label_NameBox.Location = '290, 20'
$Win.Controls.Add($Label_NameBox)

# Display the form.
$Win.ShowDialog() 

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

Now we're ready to create the empty treeview object. The text in bold below is what we're adding.

   ...
  # Put a label with the box.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "175, 23"
$Label_NameBox.Location = '10, 20'
$Win.Controls.Add($Label_NameBox)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView
$TreeView.Location = "10,40"
$TreeView.Size = "250,400"
$Win.Controls.Add($TreeView)                             # Add the tree view to the main window form.
  # Put a label with the object.
$Label_TreeView = New-Object System.Windows.Forms.Label
$Label_TreeView.Location = "10,20"
$Label_TreeView.Size = "150,25"
$Label_TreeView.Text = "My Tree Object:"
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

# Display the form.
$Win.ShowDialog()
   ...

That's a lot of code to be scrolling up and down in, and it's pretty easy to get lost in it. It'd be good if we could break up this big monolithic piece of code into smaller, more manageable chunks.

Most of this code is involved in the actual GUI-building, so the breaking up into chunks won't do a great deal for us at this point, but it will help to compartmentalize the different functional parts. We'll break this code into two pieces, and put them in separate files. On the one hand, that's a disadvantage, because now you're dealing with multiple files for your project, but on the other hand, it's an advantage, in that it breaks up the project into more manageable bite-size pieces.

So click on Edit / Select All in your PowerShell ISE, then Edit / Copy, to copy all the text into your Clipboard. Now click on click on File / New to open up a new blank tab, and then Edit / Paste to paste the script into a new tab. Go to the very bottom of the file and select everything after the "$Win.ShowDialog()" line, and then Edit / Cut that selection into the Clipboard. Now save this tab as something like "tinker_GUI.ps1".

You now have two almost identical files, one named "tinker.ps1" that has the entire script, and one named "tinker_GUI.ps1" that has everything except the last few lines.

Now go back to the "tinker.ps1" tab and with all the text still selected, Edit / Paste.

Now you have transferred all the GUI-building pieces of the script to an external file named "tinker_GUI.ps1", and left just a little bit of code in the original file, "tinker.ps1".

But the original "tinker.ps1" no longer knows anything about the code in the external "tinker_GUI.ps1" file, so we'll have to tell it about the external file. Here now is what the "tinker.ps1" file should look like, with code in bold that you need to add:


# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

Save the file, and try running it. Note that the "tinker.ps1" tab must be the active tab to run the program. If the active tab is tinker_GUI.ps1, that portion of code will run, but the tinker.ps1 portion of code won't run. In this case, it really doesn't matter all that much. That won't always be the case, though; you need to run the tab that contains the "main" piece of code.

This dot-sourcing trick essentially tells the first script to go find the referenced file and paste that file's contents in at this dot-source location. When the script runs, that second file's contents replace this line, so it's just like all that second file's contents are in this file, but without the eyeball-clutter of having those contents actually in this file.

Now we can focus just on the mechanics of working with the tree. We'll look at that in "Notes About PowerShell: Adding a TreeView to the GUI - Part 2".

Thursday, October 27, 2022

Notes About PowerShell: A More Complex GUI

Building on the simplest GUI (see here); also for more info, see The Lazy Admin), we can modify the parent window's size, title, and background color, with the following script:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$ParentWindow = New-Object System.Windows.Forms.Form

# Define the size, title, and background color
$ParentWindow.ClientSize = '500,300'
$ParentWindow.text = "My Parent Window"
$ParentWindow.BackColor = "#ffffff"
  
# Display the form
$ParentWindow.ShowDialog()

Now let's add an "OK" button:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$ParentWindow = New-Object System.Windows.Forms.Form

# Define the size, title, and background color
$ParentWindow.ClientSize = '500,300'
$ParentWindow.text = "My Parent Window"
$ParentWindow.BackColor = "#ffffff"
  
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$ParentWindow.Controls.Add($buttonOK)

# Display the form
$ParentWindow.ShowDialog()

When you run this script, you should see a rectangle inside the main window. It doesn't have a label, is in the wrong spot, and doesn't do anything. Let's make some changes. Also, "$ParentWindow" is a lot of typing. We could shorten it by two letters to "$MainWindow", but we don't have any daughter- or subordinate windows, so we could just call it "$Window", but let's save even more typing:

# Initialize the PowerShell GUI.
Add-Type -AssemblyName System.Windows.Forms

# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
# Define the size, title, and background color.
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"

# The OK button.
$Okee-Dokee = New-Object 'System.Windows.Forms.Button'    # Create the button.
$Okee-Dokee.Text = "Okely-dokely, good neighbor!"         # Puts text on the button.
$Okee-Dokee.Location = '400, 240'                         # 100 pixels from the 500 edge.
$Win.Controls.Add($Okee-Dokee)                            # Add the button to the form.
# Display the form
$Win.ShowDialog() 

Better, but still not quite there. Let's add a size spec (and rename the button control back to something more meaningful):

   ...
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'      # Create the button.
$buttonOK.Text = "Okely-dokely, good neighbor!"           # Puts text on the button.
$buttonOK.Location = '400, 240'                           # 100 pixels from the 500 edge.
$buttonOK.Size = '175, 23'
$Win.Controls.Add($buttonOK)                              # Add the button to the form.
   ...

And now move the button:

   ...
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button       # Create the button.
$buttonOK.Text = "Okely-dokely, good neighbor!"           # Puts text on the button.
$buttonOK.Location = '300, 240'                           # 200 pixels from the 500 edge.
$buttonOK.Size = '175, 23'
$Win.Controls.Add($buttonOK)                              # Add the button to the form.
   ...

Note that it doesn't matter what order these elements are defined, as long as they are defined before the "Add".

Now let's have the button do something. We'll have it close the window, and print the message "OK" to the console. (Allowable results are "None, OK, Cancel, Abort, Retry, Ignore, Yes, No".) We'll also set the button to be the "I accept the window as it is, so close it" button.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

# Create a new window form
$Win = New-Object System.Windows.Forms.Form
# Define the size, title and background color
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"

# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$buttonOK.Anchor = 'Bottom, Right'
$buttonOK.text = "Okeley-dokely, good neighbor!"
$buttonOK.Size = '175, 23'
$buttonOK.Location = '300, 240'
$buttonOK.DialogResult = "OK"
$Win.Controls.Add($buttonOK)
$Win.AcceptButton = $buttonOK

# Display the form
$Win.ShowDialog()

Write-Host("The button was pressed, returning the response: $($Win.DialogResult)")

Now let's put a text box on the form, pre-fill it with some text, pre-select that text, and then return whatever text is in the text box when the OK button is pressed. Let's also put a label atop the text. The changes to the script are bolded below.

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$Win = New-Object System.Windows.Forms.Form
# Define the size, title and background color
$Win.ClientSize = '500,300'
$Win.text = "My Parent Window"
$Win.BackColor = "#ffffff"
  
# The OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'
$buttonOK.Anchor = 'Bottom, Right'
$buttonOK.text = "Okeley-dokely, good neighbor!"
$buttonOK.Size = '175, 23'
$buttonOK.Location = '300, 240'
$buttonOK.DialogResult = "OK"
$Win.Controls.Add($buttonOK)
$Win.AcceptButton = $buttonOK
  
# The Name textbox.
$NameBox = New-Object "System.Windows.Forms.Textbox"
$NameBox.Size = "175,23"
$NameBox.Location = "10,40"
$NameBox.Text = "Babushka"
$Win.Controls.Add($NameBox)
$Win.ActiveControl = $NameBox
# And its label.
$Label_NameBox = New-Object 'System.Windows.Forms.Label'
$Label_NameBox.Text = "Enter your name:"
$Label_NameBox.Size = "175,23"
$Label_NameBox.Location = '10,20'
$Win.Controls.Add($Label_NameBox)

# Display the form
$Win.ShowDialog()
  
Write-Host("The button was pressed, returning the word: $($Win.DialogResult)")

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK")
  {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
  }
elseif ($Win.DialogResult -eq "Cancel")
  {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
  } # end of If

If you wanted to pre-select just a portion of the default text of the text box:

   ...
$NameBox.Text = "Babushka"
$NameBox.SelectionStart = 4
$NameBox.SelectionLength = 3
$Win.Controls.Add($NameBox)
   ...

Notes About PowerShell: The Simplest GUI

Create the following script (in PowerShell ISE), and then run it:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms
  
# Create a new window form
$MasterFrame = New-Object System.Windows.Forms.Form
  
# Display the form
$MasterFrame.ShowDialog()

That's all there is to creating a GUI window using PowerShell. Here's another rendition, just for comparison:

# Import the GUI pieces
Add-Type -AssemblyName System.Windows.Forms

# Create a new form<
$form_1 = New-Object System.Windows.Forms.Form
  
# Display the form
$form_1.ShowDialog()

For a more complex look, see here.

Tuesday, October 25, 2022

Notes About Powershell: Quickie About ADSI in Powershell on non-AD-bound Windows PC, to access AD

Two Methods for Accessing AD Info from Powershell

There are two basic methods for accessing Active Directory information from within Powershell scripts: using Active Directory Service Interfaces (ADSI), and the Powershell ActiveDirectory module.

This article is about ADSI.

ADSI is the method I'm using below. There are two basic ADSI tools used with this method: [adsi] is an accelerator (or "alias") to System.DirectoryServices.DirectoryEntry, which points to actual objects within AD, and [adsisearcher] is an accelerator to System.DirectoryServices.DirectorySearcher, which is used for searching through AD.

Using the ADSI Method for accessing information in Active Directory

Suppose you want to search, from within Powershell, for data in Active Directory on a Windows PC that is not bound to a domain, but is on the same network as a domain server.
 
You'll need to tell the Searcher what domain credentials to use.
 
To do this:
 
# Prompt user for creds; store them in $creds.UserName and $creds.Password
$creds = Get-Credential
 
# Specify the domain we want to search.
$DomainName = "LDAP://mydomain.com/DC=mydomain,DC=com" 

# Create a directory-entry object to that domain, with appropriate creds.
$DirEntry = New-Object `
     -TypeName System.DirectoryServices.DirectoryEntry `
     ArgumentList $DomainName,
     $creds.UserName,
     $($creds.GetNetworkCredential().Password)

We now have the Directory Entry object that points to the root of the Active Directory tree, along with the credentials needed for accessing that root.

Now we're ready to build our Searcher, and then to run it.

$Searcher = New-Object -type System.DirectoryServices.DirectorySearcher

Notice the similarity in types between the Searcher object and the Directory Entry object. That's the difference between [adsi] and [adsisearcher] you might see elsewhere.
 
Now we'll plug our Directory Entry object into the Searcher object:
 
$Searcher.SearchRoot = $DirEntry 

And now we'll do our search (not limiting it in any way; expect a deluge of info).
 
$Searcher.FindAll()

Splooge!

Sunday, October 23, 2022

Notes About Powershell: Getting Credentials

Sometimes when writing a Powershell script you need user credentials. Here are a few ways of doing that. We'll be using "johndoe" as the username, and "SuperSecret" as the password.

The Simplest Way - Hardcode them in your command

This is also the ugliest and least secure way. In this hypothetical case, the commands simply expect the username and password as arguments to the command:

PS > command_1 "johndoe" "SuperSecret"
PS > command_2 "johndoe" "SuperSecret"

Use Variables

Not quite as ugly, but still ugly.

PS > $UserName = "johndoe"
PS > $Password = "SuperSecret"
PS > command_1 $UserName $Password
PS > command_2 $UserName $Password

Prompt the User for a PSCredential Object

Now we're getting to a more secure option.

PS > $Creds = Get-Credential

This will pop up a window, prompting the user to enter his username and password. The script can then access the username with:

$Creds.UserName

and the password with:

$Creds.Password

Assuming our commands take credentials in the format of a PSCredential object, the commands might look like this:

PS > command_1 $Creds
PS > command_2 $Creds

But if they require an actual username/password, you might think that this will work:

PS > command_1 $Creds.UserName $Creds.Password
PS > command_2 $Creds.UserName $Creds.Password

But it won't, because the "$Creds.Password" value is in a special format itself, called a "SecureString". You can see this by simply typing the name of the variable:

PS > $Creds = Get-Credential
cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:

PS > $Creds

UserName                     Password
--------                     --------
johndoe  System.Security.SecureString


PS > $Creds.UserName
johndoe

PS > $Creds.Password
System.Security.SecureString

But it's easy to convert that SecureString back into plain text:

PS > $Creds.GetNetworkCredential().Password
SuperSecret

So now our commands would become:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PS > command_2 $Creds.UserName $Creds.GetNetworkCredential().Password
 
Ideally our command would be a Powershell command that natively understands the PSCredential format:

PS > PScommand_1 --Credential $Creds 

Load Up a PSCredential Object From Within the Script

This is another insecure method, but should be known about.

Since, as mentioned before, the Credential object requires a SecureString for the password, we first need to create a SecureString password:

$pwd = ConvertTo-SecureString "SuperSecret" -AsPlainText -Force

Normally the "ConvertTo-SecureString" routine expects its input to be in the format of an encrypted string of text. It's "native languages" are SecureString and Encrypted String. You can see the Encrypted String format by converting the other way:

$pwd | ConvertFrom-SecureString

which will produce something that looks like this:

01000000d08c9ddf0115d1118c7a00c04fc297eb01000000fde23916e5b4734ab18e12ca886af24900000000020000000000106600000001000020000000ff6caf244bddbe34bc6f7c8b9768317738d85d9008de0d3acaac4ee9133bc4870
00000000e80000000020000200000008ce0cd22aa3fcbb938f08f640201e1bb1c91500b679346a02e48815a385101a2200000006e2055f816b37e010e1938e59938d244c6170307ef651db8e95920f0c06b16d540000000f6b72c9148c628
c579209cd761fd0a2d6ebbdfbae888462733b06f08aec293e02f3a62b83ded1a10d24e370ab5b584bc31bf1816273e8761577b0a7455545881

So to be clear, the "ConvertTo.../...From..." routines convert (natively) between SecureString and Encrypted Strings, not to/from plain text. To include the plain text in the process, we have to tell the "ConvertTo..." routine that its input is "AsPlainText", and to get the plain text back out, we have to use a completely different routine, as we did above, the "GetNetworkCredential()" routine.

Now that we have the password as a SecureString, we can create the PSCredential object:

$Creds = New-Object System.Management.Automation.PSCredential ("johndoe", $pwd)

And now you can use the PSCredential object just as we did above:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PC > PScommand_1 --Credential $Creds

Load Up a PSCredential Object From an External File

This is kind of a compromise between the security of prompting the user for the credentials and having the credentials associated with the script itself. The password will be in an Encrypted format, and in a separate file, but it would still be pretty easy for a "hacker" to grab the file and read the password. So it's certainly not secure. You could put the external file on a secure server, but then you'd have to deal with the credentials for logging into the secure server, so all that does is kick the problem down the road a ways. Still, it might be the best we can do without going to extremes.

But the most onerous caveat about this method, at least for general purpose scripts, is that the password encryption can only be decrypted by the same user account on the same computer as was used to do the encryption. You can't write/develop the script that uses this method on one computer, and then run it on another computer, or as a different user.

Some of the following is likely a repeat of what was said above; it's a copy-and-paste from another article I had started/

Reading the Creds From a File

If you don't have a user sitting in front of the computer when the script runs, you can store the username and an encrypted form of the password in a file, beforehand, and then read it from that file when it's needed. Be aware that this is still not secure, but it's more so.

Saving the Credentials to an External File, Beforehand

Not in your script, but just at a Powershell prompt, enter the following:

PS > Set-Content -Path ".\extras.zip" -Value "johndoe"

This step creates a file named "extras.zip", over-writing any existing files of that name, in the current directory. The file contains the username. You can verify the contents of this newly-created file with:

PS > type ".\extras.zip"

As you can see, it's not really a .zip file; it's just a plain text file. But naming it as a .zip is a minor mindgame, hoping to discourage the casual "hackers" from bothering to try and open / look into the file. It's a weak form of "security via obscurity"; probably completely useless, but I know when I'm casually looking at files with which I'm unfamiliar, I tend to look at .txt files and to leave .zip files along. But you can name your file any way you want.

PS > Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)

This step adds the username to the now-already-existing "extras.zip" file. The value that gets added to the file is the password, which before getting added to the file, is piped to a converter that takes the plain text of "SuperSecret" and converts it to a SecureString.

You should now have a file named "extras.zip" with something like the following (which you can see with "type .\extras.zip":

johndoe 01000000d08c9ddf0115d1118c7a00c04fc297eb010000001150a84662a6cd45af512286977fabcc00000000020000000000106600000001000020000000943962d675717d4a12cfe44a8a22b6a5f0ca77762c7bb61e6929515af684db5d000000000e80000000020000200000006370779b6f82983d4f417eccad5be5a80b0ae8a71d2820e736862d2b25453ce220000000c96767ea12fd28ab840fcefb49b4f86d84e97f4676e806a8d7511a86532d3c3f400000008a26d44b1d1df760436be9be186cb87a6cca6220364752e435af61188330043ac201f634ff88fd751b17aff9394c00e6ada09a2f1dd93c27702f9802e813f90d

Normally the ConvertTo-SecureString expects its input to be a standard encrypted string of plain text, like the line with numbers above. But we're starting with the plain text of "SuperSecret". If you feed it this plain unencrypted text, it complains. You can see this for yourself with this command:

PS > "SuperSecret" | ConvertTo-SecureString

That's why we use the "-AsPlainText" argument, to override that behavior. You can see this for yourself like so:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText

But then you get another complaint. For security reasons, the command doesn't like to be given secret info out in the open, but you can force it to accept the input anyway, with:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force

and now you see that the result is a SecureString.

However, we can not write this SecureString out to a file, and then recover it later. But what we can do is reverse the process, part-way. We won't reverse it back to a plain-text "SuperSecret", but rather to an encrypted-text form of "SuperSecret", by piping the above command to the counterpart:

PS > 'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString

The "native language" of these two tools is SecureStrings on one side and encrypted text on the other, so we don't have to specify anything special; it just takes the SecureString in the previous command and converts from that into encrypted text.

Now that we have the credentials stored in an external file, we have to tell our script to read them.

Reading the Credentials from an External File
PS > $contents = Get-Content -Path ".\extras.zip"
PS > $Creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

Use the PSCredential Object

And now you can use the PSCredential object just as we did above:

PS > command_1 $Creds.UserName $Creds.GetNetworkCredential().Password
PC > PScommand_1 --Credential $Creds

Tuesday, October 18, 2022

Set Up Bridged Networking on Debian for QEMU/KVM (Virtual Machine Manager)

Step One: Create a New Bridged Network Interface

Without the VM running, create a virtual bridged interface. Create a file named "br0" in /etc/networking/interfaces.d, with the following contents:

auto br0
iface br0 inet dhcp
   pre-up ip tuntap add dev tap0 mode tap user <username who will run virtual machine>
   pre-up ip link set tap0 up
   bridge_ports all tap0
   bridge_stp off
   bridge_maxwait 0
   bridge_fd      0
   post-down ip link set tap0 down
   post-down ip tuntap del dev tap0 mode tap

Step Two: Restart networking.

Before you restart networking, and then again after you restart networking, you can run:

ip link show type bridge

and

ip link show master br0

to see some before-and-after stats on what you're accomplishing.

sudo systemctrl restart networking

or

sudo /etc/init.d/networking restart

Start Virtual Machine Manager and configure the VM to use the new bridged interface.

Once the Virtual Machine Manager is running, Open the desired VM, and then in the View menu, select Details. Select the NIC... item in the left-hand pane, and then in the right-hand pane, change the Network Source from "Virtual network 'default' : NAT" to "Bridged device...", and then in the Device name: field, enter "br0" to match the name of the interface defined in Step One above.

Now power-on your VM, and it should connect via bridged networking instead of being NAT-ted.

CAVEAT:  As I discovered later, this does not work with a wi-fi host; the host needs to be wired to Ethernet. I think it can be made to work (at least in some cases), but it looks to be complicated.

Monday, October 17, 2022

Notes About Powershell: Very Basic Powershell ISE Usage

Start by opening the Powershell ISE. It should be available in the "Type here to search" window of Windows 10, or similar. Note that the ISE (Integrated Scripting Environment) is essentially a simple Integrated Development Environment (IDE). Initially, it'll likely open only to a blue screen where you are in the Powershell "shell", where you can type commands, etc. For example, type:

dir

to get a listing of the current directory. This is a backwards-compatible (to the days of MS-DOS, even) command. The Powershell equivalent of doing the same thing is:

Get-Childitem

Yes, that does seem like over-kill, but Microsoft is going for consistency within the Powershell environment, with every command having an approved verb, Get in this case, followed by the "gist" of what the command is about, with the Childitem referring to the directory items.

You can put these Powershell-type commands in a script, to run like a program. To do this, go to File/New to open a new script window.

In here, you can put your dir command. Or better, use the Powershell equivalent: Get-Childitem. You might see the ISE trying to help you find the correct command/item with it's type-ahead prompts. (And you might not; it seems to be pretty hit-or-miss.)

Now, run your script by clicking on the green arrow in the menubar area. The results should show in the blue shell area below the text editing area.

Now if you save the script to a file, it'll automatically save with a ".ps1" extension, and you can open it later for editing. But once you save it, Powershell considers it a "real" script, and will refuse to run it because running scripts is against the default Powershell policy. Give it a try to see what I mean (assuming it hasn't already been fixed on your machine).

To fix that, fire up a Powershell session as Administrator, and run:

PS > Set-ExecutionPolicy

and select the "All" option. You can then exit out of the Admin session of Powershell, and restart your normal-user session of the Powershell ISE, and you should now be able to run your script.

Notes About Powershell: Accelerators

Accelerators

It is my understanding that many items in square brackets are "accelerators", which are what I would know as "aliases". You can get a list of Powershell objects that are accelerators like this:

PS >  [System.Management.Automation.PSObject].Assembly.GetType("System.Management.Automation.TypeAccelerators")::get

Key                          Value                                                              
---                          -----                                                              
Alias                        System.Management.Automation.AliasAttribute                        
AllowEmptyCollection         System.Management.Automation.AllowEmptyCollectionAttribute         
AllowEmptyString             System.Management.Automation.AllowEmptyStringAttribute             
AllowNull                    System.Management.Automation.AllowNullAttribute                    
ArgumentCompleter            System.Management.Automation.ArgumentCompleterAttribute            
array                        System.Array                                                       
bool                         System.Boolean
  ...
pslistmodifier               System.Management.Automation.PSListModifier                        
psobject                     System.Management.Automation.PSObject                              
pscustomobject               System.Management.Automation.PSObject                              
psprimitivedictionary        System.Management.Automation.PSPrimitiveDictionary                 
  ...
CimSession                   Microsoft.Management.Infrastructure.CimSession                     
adsi                         System.DirectoryServices.DirectoryEntry                            
adsisearcher                 System.DirectoryServices.DirectorySearcher                         
wmiclass                     System.Management.ManagementClass                                  
wmi                          System.Management.ManagementObject                                 
wmisearcher                  System.Management.ManagementObjectSearcher                         
mailaddress                  System.Net.Mail.MailAddress                                        
scriptblock                  System.Management.Automation.ScriptBlock                           
psvariable                   System.Management.Automation.PSVariable                            
  ...

Notice the psobject accelerator. It's an accelerator ("alias") to System.Management.Automation.PSObject. This means that the above command can be shortened to:

PS >   [psobject].Assembly.GetType("System.Management.Automation.TypeAccelerators")::get

Read more about accelerators here.

Saturday, October 15, 2022

Notes About Powershell, adsi, Active Directory Module, alternative authentication, Basics of Accessing Active Directory

Two Methods for Accessing AD Info from Powershell

There are two basic methods for accessing Active Directory information from within Powershell scripts: using Active Directory Service Interfaces (ADSI), and the Powershell ActiveDirectory module.

The second method, using the ActiveDirectory module, is native to Powershell, but is not "built-in" to most Powershell installations. Therefore, if you're planning for your Powershell to run on multiple computers, you have to take actions to make sure tht module is installed. This adds complexity to your script, and possible time to the run-time of the script.

The first method is "built in" to Powershell (sort of), but not native to Powershell. It is basically an import from the .NET system that is already installed on most Windows systems. It is this immediate and reliable availability that makes me prefer the ADSI method over the ActiveDirectory module method. Otherwise I'd stick with the pure Powershell method.

ADSI is the method I'm using below. There are two basic ADSI tools used with this method: [adsi] is an accelerator (or "alias") to System.DirectoryServices.DirectoryEntry, which points to actual objects within AD, and [adsisearcher] is an accelerator to System.DirectoryServices.DirectorySearcher, which is used for searching through AD.

Using the ADSI Method for accessing information in Active Directory

On a Machine Already Bound to an AD Domain

To access Active Directory (AD) data, we must bind to the directory. If your computer is attached to a network on which resides an AD controller, and your computer is already bound to that controller's domain, and you're logged into that domain, you can simply run:

[adsi]''

at a Powershell prompt, or from a Powershell script. This command will return the distinguishedName of the domain to which the computer is bound. Your results should be something like this:

distinguishedName : {DC=acu,DC=local}
  Path              : 

adsi is an accelerator ("alias" - see here for more info) for System.DirectoryServices.DirectoryEntry. The equivalent command is:

[System.DirectoryServices.DirectoryEntry]''

I mention using adsi because you might see it elsewhere. But for clarity, I'll use the more verbose verbiage, System.DirectoryServices.DirectoryEntry.

On a Machine Not Bound to an AD Domain

If your computer is not bound to an AD controller, you'll get results like this:

PS C:\Users\westk> [adsi]""
format-default : The following exception occurred while retrieving member "distinguishedName": "The specified domain either does not exist or could not be 
contacted.
"
    + CategoryInfo          : NotSpecified: (:) [format-default], ExtendedTypeSystemException
    + FullyQualifiedErrorId : CatchFromBaseGetMember,Microsoft.PowerShell.Commands.FormatDefaultCommand

(Notice the quotes can be single or double; sometimes one will work better than the other depending on the situation.)

The computer I'm working with is not currently bound to the domain, but it is on the same network as the domain. In order to get AD info, I have to bind the computer in some way to the domain. This will require credentials for logging into the domain. If the computer were already on the domain, and I logged in as a domain user, this method would use my Windows login credentials by default. But we can specify different credentials, or just provide credentials if the computer is not already on the domain.

The System.DirectoryServices.DirectoryEntry method is picky about how credentials are presented. You have to give it the type of connection being made ("LDAP", as opposed to "WinNT", or one other method which I can't recall at the moment), and a username and a password that has domain permissions to read from the domain. For reading from this part of the domain, almost any domain user will suffice.

When giving this data to System.DirectoryServices.DirectoryEntry, the object it returns is "not compatible" with just spitting out the results to the console like it is when not giving this information; you'll need to declare the results as a new object. We'll call the new object "$root", since we're starting at the root of the Active Directory domain tree. If we wanted, we could call it "domain", or "DomainDN", or "bub". Suppose the username you're using to bind with is "johndoe", and his password is "SuperSecret". The command could thus look like this:

$root = New-Object System.DirectoryServices.DirectoryEntry("LDAP://acu.local/DC=acu,DC=local","acu.local\johndoe","SuperSecret")

Notice that the username field includes the domain name component.

When we look at the results:

PS > $root

this should produce output like this:

distinguishedName : {DC=acu,DC=local}
Path              : LDAP://acu.local/DC=acu,DC=local

We could also write the one-liner variable-assignment as several lines (ending some lines with a back-tic to signify that the line is continued), perhaps making the declaration more readable:

$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList "LDAP://acu.local/DC=acu,DC=local",
	"acu.local\johndoe",
	"SuperSecret"

Being a Little More Secure With the Password

Although you can feed the credentials directly to this command as strings, that's not a very secure way to do it in a script. So let's use variables instead, including a variable for the path within the AD tree:

$UserName = "johndoe"
$Password = "SuperSecret"
$AD_Path = "LDAP://acu.local/DC=acu,DC=local"
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$UserName,
	$Password

Now we'd need to do something about those credential assignments being out in the open like that.

Secure-Prompting the User

If your script is going to be interactively run by a user sitting in front of the computer, you can (securely) prompt the user for the creds, like so:

$creds = Get-Credential
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$($creds.UserName),
	$($creds.GetNetworkCredential().Password)

(You can also prompt with a pre-loaded username field with $creds = Get-Credential -Credential "acu.local\johndoe" . Note also that this process doesn't do anything; it just puts these two values in the $creds variable, making the previous variables, $UserName and $Passord, superflous; get rid of them from your script.)

Note that the $creds.Password must be manipulated a bit to get it in an acceptable form to be used by this command. Looking at the values of the various objects...

PS C:\Users\westk> $creds

UserName                            Password
--------                            --------
acu.local\johndoe System.Security.SecureString


PS C:\Users\westk> $creds.UserName
acu.local\johndoe

PS C:\Users\westk> $creds.Password
System.Security.SecureString

PS C:\Users\westk> $creds.GetNetworkCredential().Password
SuperSecret

you can start to understand the manipulations involved.

Reading the Creds From a File

If you don't have a user sitting in front of the computer when the script runs, you can store the username and an encrypted form of the password in a file, beforehand, and then read it from that file when it's needed. Be aware that this is still not secure, but it's more so.

Also, the most onerous caveat about this method, at least for general purpose scripts, is that the password encryption can only be decrypted by the same user account on the same computer as was used to do the encryption. You can't write/develop the script that uses this method on one computer, and then run it on another computer, or as a different user.

Saving the Credentials to an External File, Beforehand

Not in your script, but just at a Powershell prompt, enter the following:

Set-Content -Path ".\extras.zip" -Value "acu.local\johndoe"

This step creates a file named "extras.zip", over-writing any existing files of that name, in the current directory. The file contains the username. You can verify the contents of this newly-created file with:

type ".\extras.zip"

As you can see, it's not really a .zip file; it's just a plain text file. But naming it as a .zip is a minor mindgame, hoping to discourage the casual "hackers" from bothering to try and open / look into the file. It's a weak form of "security via obscurity"; probably completely useless, but I know when I'm casually looking at files with which I'm unfamiliar, I tend to look at .txt files and to leave .zip files along. But you can name your file any way you want.

Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)

This step adds the username to the now-already-existing "extras.zip" file. The value that gets added to the file is the password, which before getting added to the file, is piped to a converter that takes the plain text of "SuperSecret" and converts it to a SecureString.

You should now have a file named "extras.zip" with something like the following (which you can see with "type .\extras.zip":

acu.local\johndoe
01000000d08c9ddf0115d1118c7a00c04fc297eb010000001150a84662a6cd45af512286977fabcc00000000020000000000106600000001000020000000943962d675717d4a12cfe44a8a22b6a5f0ca77762c7bb61e6929515af684db5d000000000e80000000020000200000006370779b6f82983d4f417eccad5be5a80b0ae8a71d2820e736862d2b25453ce220000000c96767ea12fd28ab840fcefb49b4f86d84e97f4676e806a8d7511a86532d3c3f400000008a26d44b1d1df760436be9be186cb87a6cca6220364752e435af61188330043ac201f634ff88fd751b17aff9394c00e6ada09a2f1dd93c27702f9802e813f90d

Normally the ConvertTo-SecureString expects its input to be a standard encrypted string of plain text, like the line with numbers above. But we're starting with the plain text of "SuperSecret". If you feed it this plain unencrypted text, it complains. You can see this for yourself with this command:

"SuperSecret" | ConvertTo-SecureString

That's why we use the "-AsPlainText" argument, to override that behavior. You can see this for yourself like so:

'SuperSecret" | ConvertTo-SecureString -AsPlainText

But then you get another complaint. For security reasons, the command doesn't like to be given secret info out in the open, but you can force it to accept the input anyway, with:

'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force

and now you see that the result is a SecureString.

However, we can not write this SecureString out to a file, and then recover it later. But what we can do is reverse the process, part-way. We won't reverse it back to a plain-text "SuperSecret", but rather to an encrypted-text form of "SuperSecret", by piping the above command to the counterpart:

'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString

The "native language" of these two tools is SecureStrings on one side and encrypted text on the other, so we don't have to specify anything special; it just takes the SecureString in the previous command and converts from that into encrypted text.

Now that we have the credentials stored in an external file, we have to tell our script to read them.

Reading the Credentials from an External File

In your script, replace the "$creds = Get-Credential" line like so:

# $creds = Get-Credential
$contents = Get-Content -Path ".\extras.zip"       # Read the file's contents.
# Reconvert the encrypted password back to a SecureString, and put both username and password into a new PSCredential object named $creds.
$creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

A Whole Script

Here's a whole script, so you can see the big picture.

<#  Powershell Script To Access An Active Directory Tree
  Written By:  [your name]
  Date: [today's date]
  [any ther comments you want to add]
#>
  
$creds = Get-Credential
$AD_Path = "LDAP://acu.local/DC=acu,DC=local"
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$($creds.UserName),
	$($creds.GetNetworkCredential().Password)
Write-Output("The Distinguished (`"unique`") Name for the AD domain is: $($root.distinguishedName)")

Save that as a Powershell script file, and run it, and you should see output similar to:

The Distinguished ("unique") Name for the AD domain is: DC=acu,DC=local

A Second Whole Script, Slightly Different, for Perspective

#  Powershell Script To Access An Active Directory Tree
$Username_And_Password = [PSCustomObject]@{
	UserName = "maryjane"
	Password = "SuperTramp"
}
$TreeRoot = "LDAP://acu.local/DC=acu,DC=local"
$DirEntry = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $($TreeRoot),
	$($Username_And_Password.UserName),
	$($Username_And_Password.Password)
Write-Output("The root is: $($TreeRoot).")
Write-Output("The password for $($Username_And_Password.UserName) is $($Username_And_Password.Password).")

One More Example

$AD_Node = adsi New-Object ("LDAP://DC=my_company,DC=com","my_company\accountant","time=$")
Write-Output("The domain info = $($AD_Node | Get-Member).")

which will produce output like this:

The domain info = static string ConvertDNWithBinaryToString(psobject deInstance, psobject dnWithBinaryInstance) static
 long ConvertLargeIntegerToInt64(psobject deInstance, psobject largeIntegerInstance) System.DirectoryServices.Property
ValueCollection auditingPolicy {get;set;} System.DirectoryServices.PropertyValueCollection creationTime {get;set;} Sys
tem.DirectoryServices.PropertyValueCollection dc {get;set;} System.DirectoryServices.PropertyValueCollection distingui
shedName {get;set;} System.DirectoryServices.PropertyValueCollection dSASignature {get;set;} System.DirectoryServices.
PropertyValueCollection dSCorePropagationData {get;set;} System.DirectoryServices.PropertyValueCollection forceLogoff 
{get;set;} System.DirectoryServices.PropertyValueCollection fSMORoleOwner {get;set;} System.DirectoryServices.Property
ValueCollection gPLink {get;set;} System.DirectoryServices.PropertyValueCollection instanceType {get;set;} System.Dire
ctoryServices.PropertyValueCollection isCriticalSystemObject {get;set;} System.DirectoryServices.PropertyValueCollecti
on lockoutDuration {get;set;} System.DirectoryServices.PropertyValueCollection lockOutObservationWindow {get;set;} Sys
tem.DirectoryServices.PropertyValueCollection lockoutThreshold {get;set;} System.DirectoryServices.PropertyValueCollec
tion masteredBy {get;set;} System.DirectoryServices.PropertyValueCollection maxPwdAge {get;set;} System.DirectoryServi
ces.PropertyValueCollection minPwdAge {get;set;} System.DirectoryServices.PropertyValueCollection minPwdLength {get;se
t;} System.DirectoryServices.PropertyValueCollection modifiedCount {get;set;} System.DirectoryServices.PropertyValueCo
llection modifiedCountAtLastProm {get;set;} System.DirectoryServices.PropertyValueCollection ms-DS-MachineAccountQuota
 {get;set;} System.DirectoryServices.PropertyValueCollection msDS-AllUsersTrustQuota {get;set;} System.DirectoryServic
es.PropertyValueCollection msDS-Behavior-Version {get;set;} System.DirectoryServices.PropertyValueCollection msDS-Expi
rePasswordsOnSmartCardOnlyAccounts {get;set;} System.DirectoryServices.PropertyValueCollection msDS-IsDomainFor {get;s
et;} System.DirectoryServices.PropertyValueCollection msDs-masteredBy {get;set;} System.DirectoryServices.PropertyValu
eCollection msDS-NcType {get;set;} System.DirectoryServices.PropertyValueCollection msDS-PerUserTrustQuota {get;set;} 
System.DirectoryServices.PropertyValueCollection msDS-PerUserTrustTombstonesQuota {get;set;} System.DirectoryServices.
PropertyValueCollection name {get;set;} System.DirectoryServices.PropertyValueCollection nextRid {get;set;} System.Dir
ectoryServices.PropertyValueCollection nTMixedDomain {get;set;} System.DirectoryServices.PropertyValueCollection nTSec
urityDescriptor {get;set;} System.DirectoryServices.PropertyValueCollection objectCategory {get;set;} System.Directory
Services.PropertyValueCollection objectClass {get;set;} System.DirectoryServices.PropertyValueCollection objectGUID {g
et;set;} System.DirectoryServices.PropertyValueCollection objectSid {get;set;} System.DirectoryServices.PropertyValueC
ollection otherWellKnownObjects {get;set;} System.DirectoryServices.PropertyValueCollection pwdHistoryLength {get;set;
} System.DirectoryServices.PropertyValueCollection pwdProperties {get;set;} System.DirectoryServices.PropertyValueColl
ection replUpToDateVector {get;set;} System.DirectoryServices.PropertyValueCollection repsFrom {get;set;} System.Direc
toryServices.PropertyValueCollection repsTo {get;set;} System.DirectoryServices.PropertyValueCollection rIDManagerRefe
rence {get;set;} System.DirectoryServices.PropertyValueCollection serverState {get;set;} System.DirectoryServices.Prop
ertyValueCollection subRefs {get;set;} System.DirectoryServices.PropertyValueCollection systemFlags {get;set;} System.
DirectoryServices.PropertyValueCollection uASCompat {get;set;} System.DirectoryServices.PropertyValueCollection uSNCha
nged {get;set;} System.DirectoryServices.PropertyValueCollection uSNCreated {get;set;} System.DirectoryServices.Proper
tyValueCollection wellKnownObjects {get;set;} System.DirectoryServices.PropertyValueCollection whenChanged {get;set;} 
System.DirectoryServices.PropertyValueCollection whenCreated {get;set;}.

Get The Next Level of Objects in the Domain Tree

After the last example, we now have the top lovel of our domain ("acu" in my case) in the variable $TreeRoot (or "$AD_Path", or "$root", or "AD_Node", or whatever variable name you chose to use; I'll use $Monkey in the following text, because we'll be watching where the monkey has climbed to in the tree).

Also, I have replaced the multi-line definition of the $Monkey object with the one-liner version.

Here's the script I currently have:

<# Scan_AD_Tree

    .DESCRIPTION
    This script accesses an Active Directory Tree

    .AUTHOR
    Kent West
    October 2022
#>

# These creds of a domain user will be used to bind to the domain.
# We can either prompt the user for those credentials...
# $creds = Get-Credential

# ... or we can get the creds from an external text file, assuming the file has been previously created, with:
# Set-Content -Path ".\extras.zip" -Value "acu.local\johndoe"
# Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)
$contents = Get-Content -Path ".\extras.zip"   # This reads the file, auto-making the "$contents" an array variable.
# Reconvert the encrypted-text password back to a SecureString, and put both username and password into a new PSCredential object named $creds.
$creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

# This is the LDAP "path" to the root of our AD domain "tree" named "acu.local".
$Tree_Path = "LDAP://acu.local/DC=acu,DC=local"

# We'll bind our tree-climber ("$Monkey") to the tree's path, currently at the root, using the domain user's creds.
$Monkey = New-Object System.DirectoryServices.DirectoryEntry($Tree_Path,$creds.UserName,$creds.GetNetworkCredential().Password)

Write-Output("The Common Name (CN) of the domain is $($Monkey.Name)."

Explanations/Comments in the Above May Not Be Entirely Accurate

This has been a learning process for me; I'm not confident I've explained things above entirely correctly, and I'm tired of chasing this rabbit, so I'm going to stop here, with a link that I started to include, along with a piece of code that references itself for no reason I can think of. In the hidden comments of the source to this page is a lot more information that doesn't really belong here, but is related.

Source Info
$DomainDN = New-Object -Type System.DirectoryServices.DirectorySearcher
$DomainDN.SearchRoot.Path