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