1 #!powershell
2 
3 # Copyright: (c) 2020, Brian Scholer <@briantist>
4 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5 
6 #AnsibleRequires -CSharpUtil Ansible.Basic
7 #AnsibleRequires -CSharpUtil Ansible.AccessToken
8 
9 $type = @{
10     guid    = [Func[[Object], [System.Guid]]] {
11         [System.Guid]::ParseExact($args[0].Trim([char[]]'}{').Replace('-', ''), 'N')
12     }
13     version = [Func[[Object], [System.Version]]] {
14         [System.Version]::Parse($args[0])
15     }
16     int64   = [Func[[Object], [System.Int64]]] {
17         [System.Int64]::Parse($args[0])
18     }
19     double  = [Func[[Object], [System.Double]]] {
20         [System.Double]::Parse($args[0])
21     }
22 }
23 
24 $pssc_options = @{
25     guid                                      = @{ type = $type.guid }
26     schema_version                            = @{ type = $type.version }
27     author                                    = @{ type = 'str' }
28     description                               = @{ type = 'str' }
29     company_name                              = @{ type = 'str' }
30     copyright                                 = @{ type = 'str' }
31     session_type                              = @{ type = 'str' ; choices = @('default', 'empty', 'restricted_remote_server') }
32     transcript_directory                      = @{ type = 'path' }
33     run_as_virtual_account                    = @{ type = 'bool' }
34     run_as_virtual_account_groups             = @{ type = 'list' ; elements = 'str' }
35     mount_user_drive                          = @{ type = 'bool' }
36     user_drive_maximum_size                   = @{ type = $type.int64 }
37     group_managed_service_account             = @{ type = 'str' }
38     scripts_to_process                        = @{ type = 'list' ; elements = 'str' }
39     role_definitions                          = @{ type = 'dict' }
40     required_groups                           = @{ type = 'dict' }
41     language_mode                             = @{ type = 'str' ; choices = @('no_language', 'restricted_language', 'constrained_language', 'full_language') }
42     execution_policy                          = @{ type = 'str' ; choices = @('default', 'remote_signed', 'restricted', 'undefined', 'unrestricted') }
43     powershell_version                        = @{ type = $type.version }
44     modules_to_import                         = @{ type = 'list' ; elements = 'raw' }
45     visible_aliases                           = @{ type = 'list' ; elements = 'str' }
46     visible_cmdlets                           = @{ type = 'list' ; elements = 'raw' }
47     visible_functions                         = @{ type = 'list' ; elements = 'raw' }
48     visible_external_commands                 = @{ type = 'list' ; elements = 'str' }
49     alias_definitions                         = @{ type = 'dict' }
50     function_definitions                      = @{ type = 'dict' }
51     variable_definitions                      = @{ type = 'list' ; elements = 'dict' }
52     environment_variables                     = @{ type = 'dict' }
53     types_to_process                          = @{ type = 'list' ; elements = 'path' }
54     formats_to_process                        = @{ type = 'list' ; elements = 'path' }
55     assemblies_to_load                        = @{ type = 'list' ; elements = 'str' }
56 }
57 
58 $session_configuration_options = @{
59     name                                      = @{ type = 'str' ; required = $true }
60     processor_architecure                     = @{ type = 'str' ; choices = @('amd64', 'x86') }
61     access_mode                               = @{ type = 'str' ; choices = @('disabled', 'local', 'remote') }
62     use_shared_process                        = @{ type = 'bool' }
63     thread_apartment_state                    = @{ type = 'str' ; choices = @('mta', 'sta') }
64     thread_options                            = @{ type = 'str' ; choices = @('default', 'reuse_thread', 'use_current_thread', 'use_new_thread') }
65     startup_script                            = @{ type = 'path' }
66     maximum_received_data_size_per_command_mb = @{ type = $type.double }
67     maximum_received_object_size_mb           = @{ type = $type.double }
68     security_descriptor_sddl                  = @{ type = 'str' }
69     run_as_credential_username                = @{ type = 'str' }
70     run_as_credential_password                = @{ type = 'str' ; no_log = $true }
71 }
72 
73 $behavior_options = @{
74     state                                     = @{ type = 'str' ; choices = @('present', 'absent') ; default = 'present' }
75     lenient_config_fields                     = @{ type = 'list' ; elements = 'str' ; default = @('guid', 'author', 'company_name', 'copyright', 'description') }
76     async_timeout                             = @{ type = 'int' ; default = 300 }
77     async_poll                                = @{ type = 'int' ; default = 1 }
78 <#
79     # TODO: possible future enhancement to wait for existing connections to finish
80     # Existing connections can be found with:
81     # Get-WSManInstance -ComputerName localhost -ResourceURI shell -Enumerate
82 
83     existing_connection_timeout_seconds       = @{ type = 'int' ; default = 0 }
84     existing_connection_timeout_interval_ms   = @{ type = 'int' ; default = 500 }
85     existing_connection_timeout_action        = @{ type = 'str' ; choices = @('terminate', 'fail') ; default = 'terminate' }
86     existing_connection_wait_states           = @{ type = 'list' ; elements = 'str' ; default = @('connected') }
87 #>
88 }
89 
90 $spec = @{
91     options = $pssc_options + $session_configuration_options + $behavior_options
92     required_together = @(
93         ,@('run_as_credential_username', 'run_as_credential_password')
94     )
95     supports_check_mode = $true
96 }
97 
98 $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
99 
100 
Import-PowerShellDataFileLegacy()101 function Import-PowerShellDataFileLegacy {
102     <#
103         .SYNOPSIS
104         A pre-PowerShell 5.0 version of Import-PowerShellDataFile
105 
106         .DESCRIPTION
107         Safely imports a PowerShell Data file in PowerShell versions before 5.0
108         when the built-in command was introduced. Non-literal Path support is not included.
109     #>
110     [CmdletBinding()]
111     [OutputType([hashtable])]
112     [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression','', Justification='Required to process PS data file')]
113     param(
114         [Parameter(Mandatory=$true)]
115         [Alias('Path')]
116         [String]
117         $LiteralPath
118     )
119 
120     End {
121         $astloader = [System.Management.Automation.Language.Parser]::ParseFile($LiteralPath, [ref] $null , [ref] $null)
122         $ht = $astloader.Find({ param($ast)
123              $ast -is [System.Management.Automation.Language.HashtableAst]
124         }, $false)
125 
126         if (-not $ht) {
127             throw "Invalid PowerShell Data File."
128         }
129 
130         # SafeGetValue() is not available before PowerShell 5 anyway, so we'll do the unsafe load and just execute it.
131         # The only files we're loading are ones we generated from options, or ones that were already attached to existing
132         # session configurations.
133         # $ht.SafeGetValue()
134         Invoke-Expression -Command $ht.Extent.Text
135     }
136 }
137 
138 if (-not (Get-Command -Name 'Microsoft.PowerShell.Utility\Import-PowerShellDataFile' -ErrorAction SilentlyContinue)) {
139     New-Alias -Name 'Import-PowerShellDataFile' -Value 'Import-PowerShellDataFileLegacy'
140 }
141 
ConvertFrom-SnakeCasenull142 function ConvertFrom-SnakeCase {
143     [CmdletBinding()]
144     [OutputType([String])]
145     param(
146         [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
147         [String]
148         $SnakedString
149     )
150 
151     Process {
152         [regex]::Replace($SnakedString, '^_?.|_.', { param($m) $m.Value.TrimStart('_').ToUpperInvariant() })
153     }
154 }
155 
ConvertFrom-AnsibleOptions()156 function ConvertFrom-AnsibleOptions {
157     [CmdletBinding()]
158     [OutputType([System.Collections.IDictionary])]
159     param(
160         [Parameter(Mandatory=$true)]
161         [System.Collections.IDictionary]
162         $Params ,
163 
164         [Parameter(Mandatory=$true)]
165         [hashtable]
166         $OptionSet
167     )
168 
169     End {
170         $ret = @{}
171         foreach ($option in $OptionSet.GetEnumerator()) {
172             $raw_name = $option.Name
173             switch -Wildcard ($raw_name) {
174                 'run_as_credential_*' {
175                     $raw_name = $raw_name -replace '_[^_]+$'
176                     $name = ConvertFrom-SnakeCase -SnakedString $raw_name
177                     if (-not $ret.Contains($name)) {
178                         $un = $Params["${raw_name}_username"]
179                         if ($un) {
180                             $secpw = ConvertTo-SecureString -String $Params["${raw_name}_password"] -AsPlainText -Force
181                             $value = New-Object -TypeName PSCredential -ArgumentList $un, $secpw
182                             $ret[$name] = $value
183                         }
184                     }
185                     break
186                 }
187 
188                 default {
189                     $value = $Params[$raw_name]
190                     if ($null -ne $value) {
191                         if ($option.Value.choices) {
192                             # the options that have choices have them listed in snake_case versions of their real values
193                             $value = ConvertFrom-SnakeCase -SnakedString $value
194                         }
195                         $name = ConvertFrom-SnakeCase -SnakedString $raw_name
196                         $ret[$name] = $value
197                     }
198                 }
199             }
200         }
201 
202         $ret
203     }
204 }
205 
Write-GeneratedSessionConfigurationnull206 function Write-GeneratedSessionConfiguration {
207     [CmdletBinding()]
208     [OutputType([System.IO.FileInfo])]
209     param(
210         [Parameter(Mandatory=$true)]
211         [System.Collections.IDictionary]
212         $ParameterSet ,
213 
214         [Parameter()]
215         [String]
216         $OutFile
217     )
218 
219     End {
220         $file = if ($OutFile) {
221             $OutFile
222         }
223         else {
224             [System.IO.Path]::GetTempFileName()
225         }
226 
227         $file = $file -replace '(?<!\.pssc)$', '.pssc'
228         New-PSSessionConfigurationFile -Path $file @ParameterSet
229         [System.IO.FileInfo]$file
230     }
231 }
232 
Compare-ConfigFile()233 function Compare-ConfigFile {
234     <#
235         .SYNOPSIS
236         This function compares the existing config file to the desired
237 
238         .DESCRIPTION
239         We'll load the contents of both the desired and existing config, remove fields that shouldn't be
240         compared, then generate a new config based on the existing and compare those files.
241 
242         This could be done as a direct file compare, without loading the contents as objects.
243         The primary reasons to do it this slightly more complicated way are:
244 
245         - To ignore GUID as a value that matters: if you don't supply it a new one is generated for you,
246           but PSSessionConfigurations don't use this for anything; it's just metadata. If you supply one,
247           we want to compare it. If you don't, we shouldn't count the "mismatch" against you though.
248 
249         - To normalize the existing file based on the following stuff so we avoid unnecessary changes:
250 
251         - A file compare either has to be case insensitive (won't catch changes in values) or case sensitive
252           (will may force changes on differences that don't matter, like case differences in key values)
253 
254         - A file compare will see changes on whitespace and line ending differences; although those could be
255           accounted for in other ways, this method handles them.
256 
257         - A file compare will see changes on other non-impacting syntax style differences like indentation.
258     #>
259     [CmdletBinding()]
260     [OutputType([bool])]
261     param(
262         [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
263         [System.IO.FileInfo]
264         $ConfigFilePath ,
265 
266         [Parameter(Mandatory=$true)]
267         [System.IO.FileInfo]
268         $NewConfigFile ,
269 
270         [Parameter(Mandatory=$true)]
271         [System.Collections.IDictionary]
272         $Params ,
273 
274         [Parameter()]
275         [String[]]
276         $UseExistingIfMissing
277     )
278 
279     Process {
280         $desired_config = $NewConfigFile.FullName
281 
282         $existing_content   = Import-PowerShellDataFile -LiteralPath $ConfigFilePath.FullName
283         $desired_content    = Import-PowerShellDataFile -LiteralPath $desired_config
284 
285         $regen = $false
286         foreach ($ignorable_param in $UseExistingIfMissing) {
287             # here we're checking for the parameters that shouldn't be compared if they are in the existing
288             # config, but missing in the desired config. To account for this, we copy the value from the
289             # existing into the desired so that when we regenerate it, it'll match the existing if there
290             # aren't other changes.
291             if (-not $Params.Contains($ignorable_param) -and $existing_content.Contains($ignorable_param)) {
292                 $desired_content[$ignorable_param] = $existing_content[$ignorable_param]
293                 $regen = $true
294             }
295         }
296 
297         # re-write and read the desired config file
298         if ($regen) {
299             $NewConfigFile.Delete()
300             $desired_config = Write-GeneratedSessionConfiguration -ParameterSet $desired_content -OutFile $desired_config
301         }
302 
303         $desired_content = Get-Content -Raw -LiteralPath $desired_config
304 
305         # re-write/import the existing one too to get a pristine version
306         # this will account for unimporant case differences, comments, whitespace, etc.
307         $pristine_config    = Write-GeneratedSessionConfiguration -ParameterSet $existing_content
308         $existing_content   = Get-Content -Raw -LiteralPath $pristine_config
309 
310         # with all this de/serializing out of the way we can just do a simple case-sensitive string compare
311         $desired_content -ceq $existing_content
312 
313         Remove-Item -LiteralPath $pristine_config -Force -ErrorAction SilentlyContinue
314     }
315 }
316 
Compare-SessionOption()317 function Compare-SessionOption {
318     <#
319         .DESCRIPTION
320         This function is used for comparing the session options that don't get set in the config file.
321         This _should_ have been straightforward for anything other than RunAsCredential, except that for
322         some godforesaken reason a smattering of settings have names that differ from their parameter name.
323 
324         This list is defined internally in PowerShell here:
325         https://git.io/JfUk7
326 
327     #>
328     [CmdletBinding()]
329     [OutputType([bool])]
330     param(
331         [Parameter(Mandatory=$true)]
332         [System.Collections.IDictionary]
333         $DesiredOptions ,
334 
335         [Parameter(Mandatory=$true)]
336         [Object]
337         $ExistingOptions
338     )
339 
340     End {
341         $optnamer = @{
342             ThreadApartmentState                    = 'pssessionthreadapartmentstate'
343             ThreadOptions                           = 'pssessionthreadoptions'
344             MaximumReceivedDataSizePerCommandMb     = 'PSMaximumReceivedDataSizePerCommandMB'
345             MaximumReceivedObjectSizeMb             = 'PSMaximumReceivedObjectSizeMB'
346         } | Add-Member -MemberType ScriptMethod -Name GetValueOrKey -Value {
347             param($key)
348 
349             $val = $this[$key]
350             if ($null -eq $val) {
351                 return $key
352             }
353             else {
354                 return $val
355             }
356         } -Force -PassThru
357 
358         if ($DesiredOptions.Contains('RunAsCredential')) {
359             # since we can't retrieve/compare password, a change must always be made if a cred is specified
360             return $false
361         }
362         $smatch = $true
363         foreach ($opt in $DesiredOptions.GetEnumerator()) {
364             $smatch = $smatch -and (
365                 $existing.($optnamer.GetValueOrKey($opt.Name)) -ceq $opt.Value
366             )
367             if (-not $smatch) {
368                 break
369             }
370         }
371         return $smatch
372     }
373 }
374 
375 <#
376     For use with possible future enhancement.
377     Right now the biggest challenges to this are:
378         - Ansible's connection itself: the number doesn't go to 0 while we're here running
379           and waiting for it. I thought being async it would disappear but either that's not
380           the case or it's taking too long to do so.
381 
382         - I have not found a reliable way to determine which WinRM connection is the one used for
383           the Ansible connection. Over psrp we can use Get-PSSession -ComputerName but that won't
384           work for the winrm connection plugin.
385 
386         - Connections seem to take time to disappear. In tests when trying to start time-limited
387           sessions, like:
388           icm -computername . -scriptblock { Start-Sleep -Seconds 30 } -AsJob
389           After the time elapses the connection lingers for a little while after. Should be ok but
390           does add some challenges to writing tests.
391 
392         - Checking for instances of the shell resource looks reliable, but I'm not yet certain
393           if it captures all WinRM connections, like CIM connections. Still would be better than
394           nothing.
395 #>
396 # function Wait-WinRMConnection {
397 #     <#
398 #         .SYNOPSIS
399 #         Waits for existing WinRM connections to finish
400 
401 #         .DESCRIPTION
402 #         Finds existing WinRM connections that are in a set of states (configurable), and waits for them
403 #         to disappear, or times out.
404 #     #>
405 #     [CmdletBinding()]
406 #     param(
407 #         [Parameter(Mandatory=$true)]
408 #         [Ansible.Basic.AnsibleModule]
409 #         $Module
410 #     )
411 
412 #     End {
413 #         $action     = $Module.Params.existing_connection_timeout_action
414 #         $states     = $Module.Params.existing_connection_wait_states
415 #         $timeout_ms = [System.Math]::Min(0, $Module.Params.existing_connection_timeout_seconds) * 1000
416 #         $interval   = [System.Math]::Max([System.Math]::Min(100, $Module.Params.existing_connection_timeout_interval_ms), $timeout_ms)
417 
418 #         # Would only with psrp
419 #         $thiscon = Get-PSSession -ComputerName . | Select-Object -ExpandProperty InstanceId
420 
421 #         $sw = New-Object -TypeName System.Diagnostics.Stopwatch
422 
423 #         do {
424 #             $connections = Get-WSManInstance -ComputerName localhost -ResourceURI shell -Enumerate |
425 #                 Where-Object -FilterScript {
426 #                     $states -contains $_.State -and (
427 #                         -not $thiscon -or
428 #                         $thiscon -ne $_.ShellId
429 #                     )
430 #                 }
431 
432 #             $sw.Start()
433 #             Start-Sleep -Milliseconds $interval
434 #         } while ($connections -and $sw.ElapsedMilliseconds -lt $timeout_ms)
435 #         $sw.Stop()
436 
437 #         if ($connections -and $action -eq 'fail') {
438 #             # somehow $connections.Count sometimes is blank (not 0) but I can't figure out how that's possible
439 #             $Module.FailJson("$($connections.Count) remained after timeout.")
440 #         }
441 #     }
442 # }
443 
444 $PSDefaultParameterValues = @{
445     '*-PSSessionConfiguration:Force'        = $true
446     'ConvertFrom-AnsibleOptions:Params'     = $module.Params
447     'Wait-WinRMConnection:Module'           = $module
448 }
449 
450 $opt_pssc       = ConvertFrom-AnsibleOptions -OptionSet $pssc_options
451 $opt_session    = ConvertFrom-AnsibleOptions -OptionSet $session_configuration_options
452 
453 $existing = Get-PSSessionConfiguration -Name $opt_session.Name -ErrorAction SilentlyContinue
454 
455 try {
456     if ($opt_pssc.Count) {
457         # config file options were passed to the module, so generate a config file from those
458         $desired_config = Write-GeneratedSessionConfiguration -ParameterSet $opt_pssc
459     }
460     if ($existing) {
461         # the endpoint is registered
462         if ($existing.ConfigFilePath -and (Test-Path -LiteralPath $existing.ConfigFilePath)) {
463             # the registered endpoint uses a config file
464             if ($desired_config) {
465                 # a desired config file exists, so compare it to the existing one
466                 $content_match = $existing |
467                     Compare-ConfigFile -NewConfigFile $desired_config -Params $opt_pssc -UseExistingIfMissing (
468                         $module.Params.lenient_config_fields | ConvertFrom-SnakeCase
469                     )
470             }
471             else {
472                 # existing endpoint has a config file but no config file options were passed, so there is no match
473                 $content_match = $false
474             }
475         }
476         else {
477             # existing endpoint doesn't use a config file, so it's a match if there are also no config options passed
478             $content_match = $opt_pssc.Count -eq 0
479         }
480 
481         $session_match = Compare-SessionOption -DesiredOptions $opt_session -ExistingOptions $existing
482     }
483 
484     $state = $module.Params.state
485 
486     $create = $state -eq 'present' -and (-not $existing -or -not $content_match)
487     $remove = $existing -and ($state -eq 'absent' -or -not $content_match)
488     $session_change = -not $session_match -and $state -ne 'absent'
489 
490     $module.Result.changed = $create -or $remove -or $session_change
491 
492     # In this module, we pre-emptively remove the session configuratin if there's any change
493     # in the config file options, and then re-register later if needed.
494     # But if the RunAs credential is wrong, the register will fail, and since we already removed
495     # the existing one, it will be gone.
496     #
497     # So let's ensure we can actually use the credential by logging on with TokenUtil,
498     # that way we can fail before touching the existing config.
499     if ($opt_session.Contains('RunAsCredential')) {
500         $cred = $opt_session.RunAsCredential
501         $username = $cred.Username
502         $domain = $null
503         if ($username.Contains('\')) {
504             $domain,$username = $username.Split('\')
505         }
506         try {
507             $handle = [Ansible.AccessToken.TokenUtil]::LogonUser($username, $domain, $cred.GetNetworkCredential().Password, 'Network', 'Default')
508             $handle.Dispose()
509         }
510         catch {
511             $module.FailJson("Could not validate RunAs Credential: $($_.Exception.Message)", $_)
512         }
513     }
514 
515     if (-not $module.CheckMode) {
516         if ($remove) {
517             # Wait-WinRMConnection
518             Unregister-PSSessionConfiguration -Name $opt_session.Name
519         }
520 
521         if ($create) {
522             if ($desired_config) {
523                 $opt_session.Path = $desired_config
524             }
525             # Wait-WinRMConnection
526             $null = Register-PSSessionConfiguration @opt_session
527         }
528         elseif ($session_change) {
529             $psso = $opt_session
530             # Wait-WinRMConnection
531             Set-PSSessionConfiguration @psso
532         }
533     }
534 }
535 catch [System.Management.Automation.ParameterBindingException] {
536     $e = $_
537     if ($e.Exception.ErrorId -eq 'NamedParameterNotFound') {
538         $psv = $PSVersionTable.PSVersion.ToString(2)
539         $param = $e.Exception.ParameterName
540         $cmd = $e.InvocationInfo.MyCommand.Name
541         $message = "Parameter '$param' is not available for '$cmd' in PowerShell $psv."
542     }
543     else {
544         $message = "Unknown parameter binding error: $($e.Exception.Message)"
545     }
546 
547     $module.FailJson($message, $e)
548 }
549 finally {
550     if ($desired_config) {
551         Remove-Item -LiteralPath $desired_config -Force -ErrorAction SilentlyContinue
552     }
553 }
554 
555 $module.ExitJson()
556