diff options
Diffstat (limited to 'ansible_collections/community/windows/plugins/modules')
170 files changed, 32939 insertions, 0 deletions
diff --git a/ansible_collections/community/windows/plugins/modules/__init__.py b/ansible_collections/community/windows/plugins/modules/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/__init__.py diff --git a/ansible_collections/community/windows/plugins/modules/psexec.py b/ansible_collections/community/windows/plugins/modules/psexec.py new file mode 100644 index 00000000..2a78691d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/psexec.py @@ -0,0 +1,511 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Jordan Borean <jborean93@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: psexec +short_description: Runs commands on a remote Windows host based on the PsExec + model +description: +- Runs a remote command from a Linux host to a Windows host without WinRM being + set up. +- Can be run on the Ansible controller to bootstrap Windows hosts to get them + ready for WinRM. +options: + hostname: + description: + - The remote Windows host to connect to, can be either an IP address or a + hostname. + type: str + required: yes + connection_username: + description: + - The username to use when connecting to the remote Windows host. + - This user must be a member of the C(Administrators) group of the Windows + host. + - Required if the Kerberos requirements are not installed or the username + is a local account to the Windows host. + - Can be omitted to use the default Kerberos principal ticket in the + local credential cache if the Kerberos library is installed. + - If I(process_username) is not specified, then the remote process will run + under a Network Logon under this account. + type: str + connection_password: + description: + - The password for I(connection_user). + - Required if the Kerberos requirements are not installed or the username + is a local account to the Windows host. + - Can be omitted to use a Kerberos principal ticket for the principal set + by I(connection_user) if the Kerberos library is installed and the + ticket has already been retrieved with the C(kinit) command before. + type: str + port: + description: + - The port that the remote SMB service is listening on. + type: int + default: 445 + encrypt: + description: + - Will use SMB encryption to encrypt the SMB messages sent to and from the + host. + - This requires the SMB 3 protocol which is only supported from Windows + Server 2012 or Windows 8, older versions like Windows 7 or Windows Server + 2008 (R2) must set this to C(no) and use no encryption. + - When setting to C(no), the packets are in plaintext and can be seen by + anyone sniffing the network, any process options are included in this. + type: bool + default: yes + connection_timeout: + description: + - The timeout in seconds to wait when receiving the initial SMB negotiate + response from the server. + type: int + default: 60 + executable: + description: + - The executable to run on the Windows host. + type: str + required: yes + arguments: + description: + - Any arguments as a single string to use when running the executable. + type: str + working_directory: + description: + - Changes the working directory set when starting the process. + type: str + default: C:\Windows\System32 + asynchronous: + description: + - Will run the command as a detached process and the module returns + immediately after starting the process while the process continues to + run in the background. + - The I(stdout) and I(stderr) return values will be null when this is set + to C(yes). + - The I(stdin) option does not work with this type of process. + - The I(rc) return value is not set when this is C(yes) + type: bool + default: no + load_profile: + description: + - Runs the remote command with the user's profile loaded. + type: bool + default: yes + process_username: + description: + - The user to run the process as. + - This can be set to run the process under an Interactive logon of the + specified account which bypasses limitations of a Network logon used when + this isn't specified. + - If omitted then the process is run under the same account as + I(connection_username) with a Network logon. + - Set to C(System) to run as the builtin SYSTEM account, no password is + required with this account. + - If I(encrypt) is C(no), the username and password are sent as a simple + XOR scrambled byte string that is not encrypted. No special tools are + required to get the username and password just knowledge of the protocol. + type: str + process_password: + description: + - The password for I(process_username). + - Required if I(process_username) is defined and not C(System). + type: str + integrity_level: + description: + - The integrity level of the process when I(process_username) is defined + and is not equal to C(System). + - When C(default), the default integrity level based on the system setup. + - When C(elevated), the command will be run with Administrative rights. + - When C(limited), the command will be forced to run with + non-Administrative rights. + type: str + choices: + - limited + - default + - elevated + default: default + interactive: + description: + - Will run the process as an interactive process that shows a process + Window of the Windows session specified by I(interactive_session). + - The I(stdout) and I(stderr) return values will be null when this is set + to C(yes). + - The I(stdin) option does not work with this type of process. + type: bool + default: no + interactive_session: + description: + - The Windows session ID to use when displaying the interactive process on + the remote Windows host. + - This is only valid when I(interactive) is C(yes). + - The default is C(0) which is the console session of the Windows host. + type: int + default: 0 + priority: + description: + - Set the command's priority on the Windows host. + - See U(https://msdn.microsoft.com/en-us/library/windows/desktop/ms683211.aspx) + for more details. + type: str + choices: + - above_normal + - below_normal + - high + - idle + - normal + - realtime + default: normal + show_ui_on_logon_screen: + description: + - Shows the process UI on the Winlogon secure desktop when + I(process_username) is C(System). + type: bool + default: no + process_timeout: + description: + - The timeout in seconds that is placed upon the running process. + - A value of C(0) means no timeout. + type: int + default: 0 + stdin: + description: + - Data to send on the stdin pipe once the process has started. + - This option has no effect when I(interactive) or I(asynchronous) is + C(yes). + type: str +requirements: +- pypsexec +- smbprotocol[kerberos] for optional Kerberos authentication +notes: +- This module requires the Windows host to have SMB configured and enabled, + and port 445 opened on the firewall. +- This module will wait until the process is finished unless I(asynchronous) + is C(yes), ensure the process is run as a non-interactive command to avoid + infinite hangs waiting for input. +- The I(connection_username) must be a member of the local Administrator group + of the Windows host. For non-domain joined hosts, the + C(LocalAccountTokenFilterPolicy) should be set to C(1) to ensure this works, + see U(https://support.microsoft.com/en-us/help/951016/description-of-user-account-control-and-remote-restrictions-in-windows). +- For more information on this module and the various host requirements, see + U(https://github.com/jborean93/pypsexec). +seealso: +- module: ansible.builtin.raw +- module: ansible.windows.win_command +- module: community.windows.win_psexec +- module: ansible.windows.win_shell +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Run a cmd.exe command + community.windows.psexec: + hostname: server + connection_username: username + connection_password: password + executable: cmd.exe + arguments: /c echo Hello World + +- name: Run a PowerShell command + community.windows.psexec: + hostname: server.domain.local + connection_username: username@DOMAIN.LOCAL + connection_password: password + executable: powershell.exe + arguments: Write-Host Hello World + +- name: Send data through stdin + community.windows.psexec: + hostname: 192.168.1.2 + connection_username: username + connection_password: password + executable: powershell.exe + arguments: '-' + stdin: | + Write-Host Hello World + Write-Error Error Message + exit 0 + +- name: Run the process as a different user + community.windows.psexec: + hostname: server + connection_user: username + connection_password: password + executable: whoami.exe + arguments: /all + process_username: anotheruser + process_password: anotherpassword + +- name: Run the process asynchronously + community.windows.psexec: + hostname: server + connection_username: username + connection_password: password + executable: cmd.exe + arguments: /c rmdir C:\temp + asynchronous: yes + +- name: Use Kerberos authentication for the connection (requires smbprotocol[kerberos]) + community.windows.psexec: + hostname: host.domain.local + connection_username: user@DOMAIN.LOCAL + executable: C:\some\path\to\executable.exe + arguments: /s + +- name: Disable encryption to work with WIndows 7/Server 2008 (R2) + community.windows.psexec: + hostanme: windows-pc + connection_username: Administrator + connection_password: Password01 + encrypt: no + integrity_level: elevated + process_username: Administrator + process_password: Password01 + executable: powershell.exe + arguments: (New-Object -ComObject Microsoft.Update.Session).CreateUpdateInstaller().IsBusy + +- name: Download and run ConfigureRemotingForAnsible.ps1 to setup WinRM + community.windows.psexec: + hostname: '{{ hostvars[inventory_hostname]["ansible_host"] | default(inventory_hostname) }}' + connection_username: '{{ ansible_user }}' + connection_password: '{{ ansible_password }}' + encrypt: yes + executable: powershell.exe + arguments: '-' + stdin: | + $ErrorActionPreference = "Stop" + $sec_protocols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::SystemDefault + $sec_protocols = $sec_protocols -bor [Net.SecurityProtocolType]::Tls12 + [Net.ServicePointManager]::SecurityProtocol = $sec_protocols + $url = "https://github.com/ansible/ansible/raw/devel/examples/scripts/ConfigureRemotingForAnsible.ps1" + Invoke-Expression ((New-Object Net.WebClient).DownloadString($url)) + exit + delegate_to: localhost +''' + +RETURN = r''' +msg: + description: Any exception details when trying to run the process + returned: module failed + type: str + sample: 'Received exception from remote PAExec service: Failed to start "invalid.exe". The system cannot find the file specified. [Err=0x2, 2]' +stdout: + description: The stdout from the remote process + returned: success and interactive or asynchronous is 'no' + type: str + sample: Hello World +stderr: + description: The stderr from the remote process + returned: success and interactive or asynchronous is 'no' + type: str + sample: Error [10] running process +pid: + description: The process ID of the asynchronous process that was created + returned: success and asynchronous is 'yes' + type: int + sample: 719 +rc: + description: The return code of the remote process + returned: success and asynchronous is 'no' + type: int + sample: 0 +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils._text import to_bytes, to_text + +PYPSEXEC_IMP_ERR = None +try: + from pypsexec import client + from pypsexec.exceptions import PypsexecException, PAExecException, \ + PDUException, SCMRException + from pypsexec.paexec import ProcessPriority + from smbprotocol.exceptions import SMBException, SMBAuthenticationError, \ + SMBResponseException + import socket + HAS_PYPSEXEC = True +except ImportError: + PYPSEXEC_IMP_ERR = traceback.format_exc() + HAS_PYPSEXEC = False + +KERBEROS_IMP_ERR = None +try: + import gssapi + # GSSAPI extension required for Kerberos Auth in SMB + from gssapi.raw import inquire_sec_context_by_oid + HAS_KERBEROS = True +except ImportError: + KERBEROS_IMP_ERR = traceback.format_exc() + HAS_KERBEROS = False + + +def remove_artifacts(module, client): + try: + client.remove_service() + except (SMBException, PypsexecException) as exc: + module.warn("Failed to cleanup PAExec service and executable: %s" + % to_text(exc)) + + +def main(): + module_args = dict( + hostname=dict(type='str', required=True), + connection_username=dict(type='str'), + connection_password=dict(type='str', no_log=True), + port=dict(type='int', required=False, default=445), + encrypt=dict(type='bool', default=True), + connection_timeout=dict(type='int', default=60), + executable=dict(type='str', required=True), + arguments=dict(type='str'), + working_directory=dict(type='str', default=r'C:\Windows\System32'), + asynchronous=dict(type='bool', default=False), + load_profile=dict(type='bool', default=True), + process_username=dict(type='str'), + process_password=dict(type='str', no_log=True), + integrity_level=dict(type='str', default='default', + choices=['default', 'elevated', 'limited']), + interactive=dict(type='bool', default=False), + interactive_session=dict(type='int', default=0), + priority=dict(type='str', default='normal', + choices=['above_normal', 'below_normal', 'high', + 'idle', 'normal', 'realtime']), + show_ui_on_logon_screen=dict(type='bool', default=False), + process_timeout=dict(type='int', default=0), + stdin=dict(type='str') + ) + result = dict( + changed=False, + ) + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=False, + ) + + process_username = module.params['process_username'] + process_password = module.params['process_password'] + use_system = False + if process_username is not None and process_username.lower() == "system": + use_system = True + process_username = None + process_password = None + + if process_username is not None and process_password is None: + module.fail_json(msg='parameters are required together when not ' + 'running as System: process_username, ' + 'process_password') + if not HAS_PYPSEXEC: + module.fail_json(msg=missing_required_lib("pypsexec"), + exception=PYPSEXEC_IMP_ERR) + + hostname = module.params['hostname'] + connection_username = module.params['connection_username'] + connection_password = module.params['connection_password'] + port = module.params['port'] + encrypt = module.params['encrypt'] + connection_timeout = module.params['connection_timeout'] + executable = module.params['executable'] + arguments = module.params['arguments'] + working_directory = module.params['working_directory'] + asynchronous = module.params['asynchronous'] + load_profile = module.params['load_profile'] + elevated = module.params['integrity_level'] == "elevated" + limited = module.params['integrity_level'] == "limited" + interactive = module.params['interactive'] + interactive_session = module.params['interactive_session'] + + priority = { + "above_normal": ProcessPriority.ABOVE_NORMAL_PRIORITY_CLASS, + "below_normal": ProcessPriority.BELOW_NORMAL_PRIORITY_CLASS, + "high": ProcessPriority.HIGH_PRIORITY_CLASS, + "idle": ProcessPriority.IDLE_PRIORITY_CLASS, + "normal": ProcessPriority.NORMAL_PRIORITY_CLASS, + "realtime": ProcessPriority.REALTIME_PRIORITY_CLASS + }[module.params['priority']] + show_ui_on_logon_screen = module.params['show_ui_on_logon_screen'] + + process_timeout = module.params['process_timeout'] + stdin = module.params['stdin'] + + if (connection_username is None or connection_password is None) and \ + not HAS_KERBEROS: + module.fail_json(msg=missing_required_lib("gssapi"), + execption=KERBEROS_IMP_ERR) + + win_client = client.Client(server=hostname, username=connection_username, + password=connection_password, port=port, + encrypt=encrypt) + + try: + win_client.connect(timeout=connection_timeout) + except SMBAuthenticationError as exc: + module.fail_json(msg='Failed to authenticate over SMB: %s' + % to_text(exc)) + except SMBResponseException as exc: + module.fail_json(msg='Received unexpected SMB response when opening ' + 'the connection: %s' % to_text(exc)) + except PDUException as exc: + module.fail_json(msg='Received an exception with RPC PDU message: %s' + % to_text(exc)) + except SCMRException as exc: + module.fail_json(msg='Received an exception when dealing with SCMR on ' + 'the Windows host: %s' % to_text(exc)) + except (SMBException, PypsexecException) as exc: + module.fail_json(msg=to_text(exc)) + except socket.error as exc: + module.fail_json(msg=to_text(exc)) + + # create PAExec service and run the process + result['changed'] = True + b_stdin = to_bytes(stdin, encoding='utf-8') if stdin else None + run_args = dict( + executable=executable, arguments=arguments, asynchronous=asynchronous, + load_profile=load_profile, interactive=interactive, + interactive_session=interactive_session, + run_elevated=elevated, run_limited=limited, + username=process_username, password=process_password, + use_system_account=use_system, working_dir=working_directory, + priority=priority, show_ui_on_win_logon=show_ui_on_logon_screen, + timeout_seconds=process_timeout, stdin=b_stdin + ) + try: + win_client.create_service() + except (SMBException, PypsexecException) as exc: + module.fail_json(msg='Failed to create PAExec service: %s' + % to_text(exc)) + + try: + proc_result = win_client.run_executable(**run_args) + except (SMBException, PypsexecException) as exc: + module.fail_json(msg='Received error when running remote process: %s' + % to_text(exc)) + finally: + remove_artifacts(module, win_client) + + if asynchronous: + result['pid'] = proc_result[2] + elif interactive: + result['rc'] = proc_result[2] + else: + result['stdout'] = proc_result[0] + result['stderr'] = proc_result[1] + result['rc'] = proc_result[2] + + # close the SMB connection + try: + win_client.disconnect() + except (SMBException, PypsexecException) as exc: + module.warn("Failed to close the SMB connection: %s" % to_text(exc)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.ps1 b/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.ps1 new file mode 100644 index 00000000..07640cee --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.ps1 @@ -0,0 +1,132 @@ +#!powershell + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$results = @{ + changed = $false +} + +###################################### +### populate sets for -validateset ### +###################################### +$categories_rc = run-command -command 'auditpol /list /category /r' +$subcategories_rc = run-command -command 'auditpol /list /subcategory:* /r' + +If ($categories_rc.item('rc') -eq 0) { + $categories = ConvertFrom-Csv $categories_rc.item('stdout') | Select-Object -expand Category* +} +Else { + Fail-Json -obj $results -message "Failed to retrive audit policy categories. Please make sure the auditpol command is functional on + the system and that the account ansible is running under is able to retrieve them. $($_.Exception.Message)" +} + +If ($subcategories_rc.item('rc') -eq 0) { + $subcategories = ConvertFrom-Csv $subcategories_rc.item('stdout') | Select-Object -expand Category* | + Where-Object { $_ -notin $categories } +} +Else { + Fail-Json -obj $results -message "Failed to retrive audit policy subcategories. Please make sure the auditpol command is functional on + the system and that the account ansible is running under is able to retrieve them. $($_.Exception.Message)" +} + +###################### +### ansible params ### +###################### +$category = Get-AnsibleParam -obj $params -name "category" -type "str" -ValidateSet $categories +$subcategory = Get-AnsibleParam -obj $params -name "subcategory" -type "str" -ValidateSet $subcategories +$audit_type = Get-AnsibleParam -obj $params -name "audit_type" -type "list" -failifempty - + +######################## +### Start Processing ### +######################## +Function Get-AuditPolicy ($GetString) { + $auditpolcsv = Run-Command -command $GetString + If ($auditpolcsv.item('rc') -eq 0) { + $Obj = ConvertFrom-CSV $auditpolcsv.item('stdout') | Select-Object @{n = 'subcategory'; e = { $_.Subcategory.ToLower() } }, + @{ n = 'audit_type'; e = { $_."Inclusion Setting".ToLower() } } + } + Else { + return $auditpolcsv.item('stderr') + } + + $HT = @{} + Foreach ( $Item in $Obj ) { + $HT.Add($Item.subcategory, $Item.audit_type) + } + $HT +} + +################ +### Validate ### +################ + +#make sure category and subcategory are valid +If (-Not $category -and -Not $subcategory) { Fail-Json -obj $results -message "You must provide either a Category or Subcategory parameter" } +If ($category -and $subcategory) { Fail-Json -obj $results -message "Must pick either a specific subcategory or category. You cannot define both" } + + +$possible_audit_types = 'success', 'failure', 'none' +$audit_type | ForEach-Object { + If ($_ -notin $possible_audit_types) { + Fail-Json -obj $result -message "$_ is not a valid audit_type. Please choose from $($possible_audit_types -join ',')" + } +} + +############################################################# +### build lists for setting, getting, and comparing rules ### +############################################################# +$audit_type_string = $audit_type -join ' and ' + +$SetString = 'auditpol /set' +$GetString = 'auditpol /get /r' + +If ($category) { $SetString = "$SetString /category:`"$category`""; $GetString = "$GetString /category:`"$category`"" } +If ($subcategory) { $SetString = "$SetString /subcategory:`"$subcategory`""; $GetString = "$GetString /subcategory:`"$subcategory`"" } + + +Switch ($audit_type_string) { + 'success and failure' { $SetString = "$SetString /success:enable /failure:enable"; $audit_type_check = $audit_type_string } + 'failure' { $SetString = "$SetString /success:disable /failure:enable"; $audit_type_check = $audit_type_string } + 'success' { $SetString = "$SetString /success:enable /failure:disable"; $audit_type_check = $audit_type_string } + 'none' { $SetString = "$SetString /success:disable /failure:disable"; $audit_type_check = 'No Auditing' } + default { Fail-Json -obj $result -message "It seems you have specified an invalid combination of items for audit_type. Please review documentation" } +} + +######################### +### check Idempotence ### +######################### + +$CurrentRule = Get-AuditPolicy $GetString + +#exit if the audit_type is already set properly for the category +If (-not ($CurrentRule.Values | Where-Object { $_ -ne $audit_type_check }) ) { + $results.current_audit_policy = Get-AuditPolicy $GetString + Exit-Json -obj $results +} + +#################### +### Apply Change ### +#################### + +If (-not $check_mode) { + $ApplyPolicy = Run-Command -command $SetString + + If ($ApplyPolicy.Item('rc') -ne 0) { + $results.current_audit_policy = Get-AuditPolicy $GetString + Fail-Json $results "Failed to set audit policy - $($_.Exception.Message)" + } +} + +$results.changed = $true +$results.current_audit_policy = Get-AuditPolicy $GetString +Exit-Json $results diff --git a/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.py b/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.py new file mode 100644 index 00000000..18a91e7b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_audit_policy_system.py @@ -0,0 +1,69 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_audit_policy_system +short_description: Used to make changes to the system wide Audit Policy +description: + - Used to make changes to the system wide Audit Policy. +options: + category: + description: + - Single string value for the category you would like to adjust the policy on. + - Cannot be used with I(subcategory). You must define one or the other. + - Changing this setting causes all subcategories to be adjusted to the defined I(audit_type). + type: str + subcategory: + description: + - Single string value for the subcategory you would like to adjust the policy on. + - Cannot be used with I(category). You must define one or the other. + type: str + audit_type: + description: + - The type of event you would like to audit for. + - Accepts a list. See examples. + type: list + elements: str + required: yes + choices: [ failure, none, success ] +notes: + - It is recommended to take a backup of the policies before adjusting them for the first time. + - See this page for in depth information U(https://technet.microsoft.com/en-us/library/cc766468.aspx). +seealso: +- module: community.windows.win_audit_rule +author: + - Noah Sparks (@nwsparks) +''' + +EXAMPLES = r''' +- name: Enable failure auditing for the subcategory "File System" + community.windows.win_audit_policy_system: + subcategory: File System + audit_type: failure + +- name: Enable all auditing types for the category "Account logon events" + community.windows.win_audit_policy_system: + category: Account logon events + audit_type: success, failure + +- name: Disable auditing for the subcategory "File System" + community.windows.win_audit_policy_system: + subcategory: File System + audit_type: none +''' + +RETURN = r''' +current_audit_policy: + description: details on the policy being targetted + returned: always + type: dict + sample: |- + { + "File Share":"failure" + } +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_audit_rule.ps1 b/ansible_collections/community/windows/plugins/modules/win_audit_rule.ps1 new file mode 100644 index 00000000..c36c8f9c --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_audit_rule.ps1 @@ -0,0 +1,174 @@ +#!powershell + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.SID + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +# module parameters +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -aliases "destination", "dest" +$user = Get-AnsibleParam -obj $params -name "user" -type "str" -failifempty $true +$rights = Get-AnsibleParam -obj $params -name "rights" -type "list" +$inheritance_flags = Get-AnsibleParam -obj $params -name "inheritance_flags" -type "list" -default 'ContainerInherit', 'ObjectInherit' +$prop_options = 'InheritOnly', 'None', 'NoPropagateInherit' +$propagation_flags = Get-AnsibleParam -obj $params -name "propagation_flags" -type "str" -default "none" -ValidateSet $prop_options +$audit_flags = Get-AnsibleParam -obj $params -name "audit_flags" -type "list" -default 'success' +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset 'present', 'absent' + +#Make sure target path is valid +If (-not (Test-Path -Path $path) ) { + Fail-Json -obj $result -message "defined path ($path) is not found/invalid" +} + +#function get current audit rules and convert to hashtable +Function Get-CurrentAuditRule ($path) { + Try { + $ACL = Get-Acl $path -Audit + } + Catch { + Return "Unable to retrieve the ACL on $Path" + } + + $HT = Foreach ($Obj in $ACL.Audit) { + @{ + user = $Obj.IdentityReference.ToString() + rights = ($Obj | Select-Object -expand "*rights").ToString() + audit_flags = $Obj.AuditFlags.ToString() + is_inherited = $Obj.IsInherited.ToString() + inheritance_flags = $Obj.InheritanceFlags.ToString() + propagation_flags = $Obj.PropagationFlags.ToString() + } + } + + If (-Not $HT) { + "No audit rules defined on $path" + } + Else { $HT } +} + +$result = @{ + changed = $false + current_audit_rules = Get-CurrentAuditRule $path +} + +#Make sure identity is valid and can be looked up +Try { + $SID = Convert-ToSid $user +} +Catch { + Fail-Json -obj $result -message "Failed to lookup the identity ($user) - $($_.exception.message)" +} + +#get the path type +$ItemType = (Get-Item $path -Force).GetType() +switch ($ItemType) { + ([Microsoft.Win32.RegistryKey]) { $registry = $true; $result.path_type = 'registry' } + ([System.IO.FileInfo]) { $file = $true; $result.path_type = 'file' } + ([System.IO.DirectoryInfo]) { $result.path_type = 'directory' } +} + +#Get current acl/audit rules on the target +Try { + $ACL = Get-Acl $path -Audit +} +Catch { + Fail-Json -obj $result -message "Unable to retrieve the ACL on $Path - $($_.Exception.Message)" +} + +#configure acl object to remove the specified user +If ($state -eq 'absent') { + #Try and find an identity on the object that matches user + #We skip inherited items since we can't remove those + $ToRemove = ($ACL.Audit | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $SID -and + $_.IsInherited -eq $false }).IdentityReference + + #Exit with changed false if no identity is found + If (-Not $ToRemove) { + $result.current_audit_rules = Get-CurrentAuditRule $path + Exit-Json -obj $result + } + + #update the ACL object if identity found + Try { + $ToRemove | ForEach-Object { $ACL.PurgeAuditRules($_) } + } + Catch { + $result.current_audit_rules = Get-CurrentAuditRule $path + Fail-Json -obj $result -message "Failed to remove audit rule: $($_.Exception.Message)" + } +} + +Else { + If ($registry) { + $PossibleRights = [System.Enum]::GetNames([System.Security.AccessControl.RegistryRights]) + + Foreach ($right in $rights) { + if ($right -notin $PossibleRights) { + Fail-Json -obj $result -message "$right does not seem to be a valid REGISTRY right" + } + } + + $NewAccessRule = New-Object System.Security.AccessControl.RegistryAuditRule($user, $rights, $inheritance_flags, $propagation_flags, $audit_flags) + } + Else { + $PossibleRights = [System.Enum]::GetNames([System.Security.AccessControl.FileSystemRights]) + + Foreach ($right in $rights) { + if ($right -notin $PossibleRights) { + Fail-Json -obj $result -message "$right does not seem to be a valid FILE SYSTEM right" + } + } + + If ($file -and $inheritance_flags -ne 'none') { + Fail-Json -obj $result -message "The target type is a file. inheritance_flags must be changed to 'none'" + } + + $NewAccessRule = New-Object System.Security.AccessControl.FileSystemAuditRule($user, $rights, $inheritance_flags, $propagation_flags, $audit_flags) + } + + #exit here if any existing rule matches defined rule since no change is needed + #if we need to ignore inherited rules in the future, this would be where to do it + #Just filter out inherited rules from $ACL.Audit + Foreach ($group in $ACL.Audit | Where-Object { $_.IsInherited -eq $false }) { + If ( + ($group | Select-Object -expand "*Rights") -eq ($NewAccessRule | Select-Object -expand "*Rights") -and + $group.AuditFlags -eq $NewAccessRule.AuditFlags -and + $group.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $SID -and + $group.InheritanceFlags -eq $NewAccessRule.InheritanceFlags -and + $group.PropagationFlags -eq $NewAccessRule.PropagationFlags + ) { + $result.current_audit_rules = Get-CurrentAuditRule $path + Exit-Json -obj $result + } + } + + #try and set the acl object. AddAuditRule allows for multiple entries to exist under the same + #identity...so if someone wanted success: write and failure: delete for example, that setup would be + #possible. The alternative is SetAuditRule which would instead modify an existing rule and not allow + #for setting the above example. + Try { + $ACL.AddAuditRule($NewAccessRule) + } + Catch { + Fail-Json -obj $result -message "Failed to set the audit rule: $($_.Exception.Message)" + } +} + + +#finally set the permissions +Try { + Set-Acl -Path $path -ACLObject $ACL -WhatIf:$check_mode +} +Catch { + $result.current_audit_rules = Get-CurrentAuditRule $path + Fail-Json -obj $result -message "Failed to apply audit change: $($_.Exception.Message)" +} + +#exit here after a change is applied +$result.current_audit_rules = Get-CurrentAuditRule $path +$result.changed = $true +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_audit_rule.py b/ansible_collections/community/windows/plugins/modules/win_audit_rule.py new file mode 100644 index 00000000..fa9bd18a --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_audit_rule.py @@ -0,0 +1,140 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_audit_rule +short_description: Adds an audit rule to files, folders, or registry keys +description: + - Used to apply audit rules to files, folders or registry keys. + - Once applied, it will begin recording the user who performed the operation defined into the Security + Log in the Event viewer. + - The behavior is designed to ignore inherited rules since those cannot be adjusted without first disabling + the inheritance behavior. It will still print inherited rules in the output though for debugging purposes. +options: + path: + description: + - Path to the file, folder, or registry key. + - Registry paths should be in Powershell format, beginning with an abbreviation for the root + such as, C(HKLM:\Software). + type: path + required: yes + aliases: [ dest, destination ] + user: + description: + - The user or group to adjust rules for. + type: str + required: yes + rights: + description: + - Comma separated list of the rights desired. Only required for adding a rule. + - If I(path) is a file or directory, rights can be any right under MSDN + FileSystemRights U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.filesystemrights.aspx). + - If I(path) is a registry key, rights can be any right under MSDN + RegistryRights U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.registryrights.aspx). + type: list + elements: str + required: yes + inheritance_flags: + description: + - Defines what objects inside of a folder or registry key will inherit the settings. + - If you are setting a rule on a file, this value has to be changed to C(none). + - For more information on the choices see MSDN PropagationFlags enumeration + at U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.inheritanceflags.aspx). + type: list + elements: str + choices: [ ContainerInherit, ObjectInherit ] + default: ContainerInherit,ObjectInherit + propagation_flags: + description: + - Propagation flag on the audit rules. + - This value is ignored when the path type is a file. + - For more information on the choices see MSDN PropagationFlags enumeration + at U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.propagationflags.aspx). + choices: [ None, InherityOnly, NoPropagateInherit ] + default: "None" + audit_flags: + description: + - Defines whether to log on failure, success, or both. + - To log both define as comma separated list "Success, Failure". + type: list + elements: str + required: yes + choices: [ Failure, Success ] + state: + description: + - Whether the rule should be C(present) or C(absent). + - For absent, only I(path), I(user), and I(state) are required. + - Specifying C(absent) will remove all rules matching the defined I(user). + type: str + choices: [ absent, present ] + default: present +seealso: +- module: community.windows.win_audit_policy_system +author: + - Noah Sparks (@nwsparks) +''' + +EXAMPLES = r''' +- name: Add filesystem audit rule for a folder + community.windows.win_audit_rule: + path: C:\inetpub\wwwroot\website + user: BUILTIN\Users + rights: write,delete,changepermissions + audit_flags: success,failure + inheritance_flags: ContainerInherit,ObjectInherit + +- name: Add filesystem audit rule for a file + community.windows.win_audit_rule: + path: C:\inetpub\wwwroot\website\web.config + user: BUILTIN\Users + rights: write,delete,changepermissions + audit_flags: success,failure + inheritance_flags: None + +- name: Add registry audit rule + community.windows.win_audit_rule: + path: HKLM:\software + user: BUILTIN\Users + rights: delete + audit_flags: 'success' + +- name: Remove filesystem audit rule + community.windows.win_audit_rule: + path: C:\inetpub\wwwroot\website + user: BUILTIN\Users + state: absent + +- name: Remove registry audit rule + community.windows.win_audit_rule: + path: HKLM:\software + user: BUILTIN\Users + state: absent +''' + +RETURN = r''' +current_audit_rules: + description: + - The current rules on the defined I(path) + - Will return "No audit rules defined on I(path)" + returned: always + type: dict + sample: | + { + "audit_flags": "Success", + "user": "Everyone", + "inheritance_flags": "False", + "is_inherited": "False", + "propagation_flags": "None", + "rights": "Delete" + } +path_type: + description: + - The type of I(path) being targetted. + - Will be one of file, directory, registry. + returned: always + type: str +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_auto_logon.ps1 b/ansible_collections/community/windows/plugins/modules/win_auto_logon.ps1 new file mode 100644 index 00000000..66a20dd9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_auto_logon.ps1 @@ -0,0 +1,404 @@ +#!powershell + +# Copyright: (c) 2019, Prasoon Karunan V (@prasoonkarunan) <kvprasoon@Live.in> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# All helper methods are written in a binary module and has to be loaded for consuming them. +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +Set-StrictMode -Version 2.0 + +$spec = @{ + options = @{ + logon_count = @{ type = "int" } + password = @{ type = "str"; no_log = $true } + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + username = @{ type = "str" } + } + required_if = @( + , @("state", "present", @("username", "password")) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$logonCount = $module.Params.logon_count +$password = $module.Params.password +$state = $module.Params.state +$username = $module.Params.username +$domain = $null + +if ($username) { + # Try and get the Netlogon form of the username specified. Translating to and from a SID gives us an NTAccount + # in the Netlogon form that we desire. + $ntAccount = New-Object -TypeName System.Security.Principal.NTAccount -ArgumentList $username + try { + $accountSid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]) + } + catch [System.Security.Principal.IdentityNotMappedException] { + $module.FailJson("Failed to find a local or domain user with the name '$username'", $_) + } + $ntAccount = $accountSid.Translate([System.Security.Principal.NTAccount]) + + $domain, $username = $ntAccount.Value -split '\\' +} + +# Make sure $null regardless of any input value if state: absent +if ($state -eq 'absent') { + $password = $null +} + +Add-CSharpType -AnsibleModule $module -References @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ansible.WinAutoLogon +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential)] + public class LSA_OBJECT_ATTRIBUTES + { + public UInt32 Length = 0; + public IntPtr RootDirectory = IntPtr.Zero; + public IntPtr ObjectName = IntPtr.Zero; + public UInt32 Attributes = 0; + public IntPtr SecurityDescriptor = IntPtr.Zero; + public IntPtr SecurityQualityOfService = IntPtr.Zero; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct LSA_UNICODE_STRING + { + public UInt16 Length; + public UInt16 MaximumLength; + public IntPtr Buffer; + + public static explicit operator string(LSA_UNICODE_STRING s) + { + byte[] strBytes = new byte[s.Length]; + Marshal.Copy(s.Buffer, strBytes, 0, s.Length); + return Encoding.Unicode.GetString(strBytes); + } + + public static SafeMemoryBuffer CreateSafeBuffer(string s) + { + if (s == null) + return new SafeMemoryBuffer(IntPtr.Zero); + + byte[] stringBytes = Encoding.Unicode.GetBytes(s); + int structSize = Marshal.SizeOf(typeof(LSA_UNICODE_STRING)); + IntPtr buffer = Marshal.AllocHGlobal(structSize + stringBytes.Length); + try + { + LSA_UNICODE_STRING lsaString = new LSA_UNICODE_STRING() + { + Length = (UInt16)(stringBytes.Length), + MaximumLength = (UInt16)(stringBytes.Length), + Buffer = IntPtr.Add(buffer, structSize), + }; + Marshal.StructureToPtr(lsaString, buffer, false); + Marshal.Copy(stringBytes, 0, lsaString.Buffer, stringBytes.Length); + return new SafeMemoryBuffer(buffer); + } + catch + { + // Make sure we free the pointer before raising the exception. + Marshal.FreeHGlobal(buffer); + throw; + } + } + } + } + + internal class NativeMethods + { + [DllImport("Advapi32.dll")] + public static extern UInt32 LsaClose( + IntPtr ObjectHandle); + + [DllImport("Advapi32.dll")] + public static extern UInt32 LsaFreeMemory( + IntPtr Buffer); + + [DllImport("Advapi32.dll")] + internal static extern Int32 LsaNtStatusToWinError( + UInt32 Status); + + [DllImport("Advapi32.dll")] + public static extern UInt32 LsaOpenPolicy( + IntPtr SystemName, + NativeHelpers.LSA_OBJECT_ATTRIBUTES ObjectAttributes, + LsaPolicyAccessMask AccessMask, + out SafeLsaHandle PolicyHandle); + + [DllImport("Advapi32.dll")] + public static extern UInt32 LsaRetrievePrivateData( + SafeLsaHandle PolicyHandle, + SafeMemoryBuffer KeyName, + out SafeLsaMemory PrivateData); + + [DllImport("Advapi32.dll")] + public static extern UInt32 LsaStorePrivateData( + SafeLsaHandle PolicyHandle, + SafeMemoryBuffer KeyName, + SafeMemoryBuffer PrivateData); + } + + internal class SafeLsaMemory : SafeBuffer + { + internal SafeLsaMemory() : base(true) { } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + + protected override bool ReleaseHandle() + { + return NativeMethods.LsaFreeMemory(handle) == 0; + } + } + + internal class SafeMemoryBuffer : SafeBuffer + { + internal SafeMemoryBuffer() : base(true) { } + + internal SafeMemoryBuffer(IntPtr ptr) : base(true) + { + base.SetHandle(ptr); + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + + protected override bool ReleaseHandle() + { + if (handle != IntPtr.Zero) + Marshal.FreeHGlobal(handle); + return true; + } + } + + public class SafeLsaHandle : SafeHandleZeroOrMinusOneIsInvalid + { + internal SafeLsaHandle() : base(true) { } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + + protected override bool ReleaseHandle() + { + return NativeMethods.LsaClose(handle) == 0; + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _exception_msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _exception_msg = String.Format("{0} - {1} (Win32 Error Code {2}: 0x{3})", message, base.Message, errorCode, errorCode.ToString("X8")); + } + public override string Message { get { return _exception_msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + [Flags] + public enum LsaPolicyAccessMask : uint + { + ViewLocalInformation = 0x00000001, + ViewAuditInformation = 0x00000002, + GetPrivateInformation = 0x00000004, + TrustAdmin = 0x00000008, + CreateAccount = 0x00000010, + CreateSecret = 0x00000020, + CreatePrivilege = 0x00000040, + SetDefaultQuotaLimits = 0x00000080, + SetAuditRequirements = 0x00000100, + AuditLogAdmin = 0x00000200, + ServerAdmin = 0x00000400, + LookupNames = 0x00000800, + Read = 0x00020006, + Write = 0x000207F8, + Execute = 0x00020801, + AllAccess = 0x000F0FFF, + } + + public class LsaUtil + { + public static SafeLsaHandle OpenPolicy(LsaPolicyAccessMask access) + { + NativeHelpers.LSA_OBJECT_ATTRIBUTES oa = new NativeHelpers.LSA_OBJECT_ATTRIBUTES(); + SafeLsaHandle lsaHandle; + UInt32 res = NativeMethods.LsaOpenPolicy(IntPtr.Zero, oa, access, out lsaHandle); + if (res != 0) + throw new Win32Exception(NativeMethods.LsaNtStatusToWinError(res), + String.Format("LsaOpenPolicy({0}) failed", access.ToString())); + return lsaHandle; + } + + public static string RetrievePrivateData(SafeLsaHandle handle, string key) + { + using (SafeMemoryBuffer keyBuffer = NativeHelpers.LSA_UNICODE_STRING.CreateSafeBuffer(key)) + { + SafeLsaMemory buffer; + UInt32 res = NativeMethods.LsaRetrievePrivateData(handle, keyBuffer, out buffer); + using (buffer) + { + if (res != 0) + { + // If the data object was not found we return null to indicate it isn't set. + if (res == 0xC0000034) // STATUS_OBJECT_NAME_NOT_FOUND + return null; + + throw new Win32Exception(NativeMethods.LsaNtStatusToWinError(res), + String.Format("LsaRetrievePrivateData({0}) failed", key)); + } + + NativeHelpers.LSA_UNICODE_STRING lsaString = (NativeHelpers.LSA_UNICODE_STRING) + Marshal.PtrToStructure(buffer.DangerousGetHandle(), + typeof(NativeHelpers.LSA_UNICODE_STRING)); + return (string)lsaString; + } + } + } + + public static void StorePrivateData(SafeLsaHandle handle, string key, string data) + { + using (SafeMemoryBuffer keyBuffer = NativeHelpers.LSA_UNICODE_STRING.CreateSafeBuffer(key)) + using (SafeMemoryBuffer dataBuffer = NativeHelpers.LSA_UNICODE_STRING.CreateSafeBuffer(data)) + { + UInt32 res = NativeMethods.LsaStorePrivateData(handle, keyBuffer, dataBuffer); + if (res != 0) + { + // When clearing the private data with null it may return this error which we can ignore. + if (data == null && res == 0xC0000034) // STATUS_OBJECT_NAME_NOT_FOUND + return; + + throw new Win32Exception(NativeMethods.LsaNtStatusToWinError(res), + String.Format("LsaStorePrivateData({0}) failed", key)); + } + } + } + } +} +'@ + +$autoLogonRegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +$logonDetails = Get-ItemProperty -LiteralPath $autoLogonRegPath + +$before = @{ + state = 'absent' +} +if ('AutoAdminLogon' -in $logonDetails.PSObject.Properties.Name -and $logonDetails.AutoAdminLogon -eq 1) { + $before.state = 'present' +} + +$mapping = @{ + DefaultUserName = 'username' + DefaultDomainName = 'domain' + AutoLogonCount = 'logon_count' +} +foreach ($map_detail in $mapping.GetEnumerator()) { + if ($map_detail.Key -in $logonDetails.PSObject.Properties.Name) { + $before."$($map_detail.Value)" = $logonDetails."$($map_detail.Key)" + } +} + +$module.Diff.before = $before + +$propParams = @{ + LiteralPath = $autoLogonRegPath + WhatIf = $module.CheckMode + Force = $true +} + +# First set the registry information +# The DefaultPassword reg key should never be set, we use LSA to store the password in a more secure way. +if ('DefaultPassword' -in (Get-Item -LiteralPath $autoLogonRegPath).Property) { + # Bug on older Windows hosts where -WhatIf causes it fail to find the property + if (-not $module.CheckMode) { + Remove-ItemProperty -Name 'DefaultPassword' @propParams + } + $module.Result.changed = $true +} + +$autoLogonKeyList = @{ + DefaultUserName = @{ + before = if ($before.ContainsKey('username')) { $before.username } else { $null } + after = $username + } + DefaultDomainName = @{ + before = if ($before.ContainsKey('domain')) { $before.domain } else { $null } + after = $domain + } + AutoLogonCount = @{ + before = if ($before.ContainsKey('logon_count')) { $before.logon_count } else { $null } + after = $logonCount + } +} + +# Check AutoAdminLogon separately as it has different logic (key must exist) +if ($state -ne $before.state) { + $newValue = if ($state -eq 'present') { 1 } else { 0 } + $null = New-ItemProperty -Name 'AutoAdminLogon' -Value $newValue -PropertyType DWord @propParams + $module.Result.changed = $true +} + +foreach ($key in $autoLogonKeyList.GetEnumerator()) { + $beforeVal = $key.Value.before + $after = $key.Value.after + + if ($state -eq 'present' -and $beforeVal -cne $after) { + if ($null -ne $after) { + $null = New-ItemProperty -Name $key.Key -Value $after @propParams + } + elseif (-not $module.CheckMode) { + Remove-ItemProperty -Name $key.Key @propParams + } + $module.Result.changed = $true + } + elseif ($state -eq 'absent' -and $null -ne $beforeVal) { + if (-not $module.CheckMode) { + Remove-ItemProperty -Name $key.Key @propParams + } + $module.Result.changed = $true + } +} + +# Finally update the password in the LSA private store. +$lsaHandle = [Ansible.WinAutoLogon.LsaUtil]::OpenPolicy('CreateSecret, GetPrivateInformation') +try { + $beforePass = [Ansible.WinAutoLogon.LsaUtil]::RetrievePrivateData($lsaHandle, 'DefaultPassword') + + if ($beforePass -cne $password) { + # Due to .NET marshaling we need to pass in $null as NullString.Value so it's truly a null value. + if ($null -eq $password) { + $password = [NullString]::Value + } + if (-not $module.CheckMode) { + [Ansible.WinAutoLogon.LsaUtil]::StorePrivateData($lsaHandle, 'DefaultPassword', $password) + } + $module.Result.changed = $true + } +} +finally { + $lsaHandle.Dispose() +} + +# Need to manually craft the after diff in case we are running in check mode +$module.Diff.after = @{ + state = $state +} +if ($state -eq 'present') { + $module.Diff.after.username = $username + $module.Diff.after.domain = $domain + if ($null -ne $logonCount) { + $module.Diff.after.logon_count = $logonCount + } +} + +$module.ExitJson() + diff --git a/ansible_collections/community/windows/plugins/modules/win_auto_logon.py b/ansible_collections/community/windows/plugins/modules/win_auto_logon.py new file mode 100644 index 00000000..127725fb --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_auto_logon.py @@ -0,0 +1,72 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Prasoon Karunan V (@prasoonkarunan) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_auto_logon +short_description: Adds or Sets auto logon registry keys. +description: + - Used to apply auto logon registry setting. +options: + logon_count: + description: + - The number of times to do an automatic logon. + - This count is deremented by Windows everytime an automatic logon is + performed. + - Once the count reaches C(0) then the automatic logon process is + disabled. + type: int + username: + description: + - Username to login automatically. + - Must be set when C(state=present). + - This can be the Netlogon or UPN of a domain account and is + automatically parsed to the C(DefaultUserName) and C(DefaultDomainName) + registry properties. + type: str + password: + description: + - Password to be used for automatic login. + - Must be set when C(state=present). + - Value of this input will be used as password for I(username). + - While this value is encrypted by LSA it is decryptable to any user who + is an Administrator on the remote host. + type: str + state: + description: + - Whether the registry key should be C(present) or C(absent). + type: str + choices: [ absent, present ] + default: present +author: + - Prasoon Karunan V (@prasoonkarunan) +''' + +EXAMPLES = r''' +- name: Set autologon for user1 + community.windows.win_auto_logon: + username: User1 + password: str0ngp@ssword + +- name: Set autologon for abc.com\user1 + community.windows.win_auto_logon: + username: abc.com\User1 + password: str0ngp@ssword + +- name: Remove autologon for user1 + community.windows.win_auto_logon: + state: absent + +- name: Set autologon for user1 with a limited logon count + community.windows.win_auto_logon: + username: User1 + password: str0ngp@ssword + logon_count: 5 +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_certificate_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_certificate_info.ps1 new file mode 100644 index 00000000..004cd3e3 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_certificate_info.ps1 @@ -0,0 +1,115 @@ +#!powershell + +# Copyright: (c) 2019, Micah Hunsberger +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +function ConvertTo-Timestamp($start_date, $end_date) { + if ($start_date -and $end_date) { + return (New-TimeSpan -Start $start_date -End $end_date).TotalSeconds + } +} + +function Format-Date([DateTime]$date) { + return $date.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssK') +} + +function Get-CertificateInfo ($cert) { + $epoch_date = Get-Date -Date "01/01/1970" + + $cert_info = @{ extensions = @() } + $cert_info.friendly_name = $cert.FriendlyName + $cert_info.thumbprint = $cert.Thumbprint + $cert_info.subject = $cert.Subject + $cert_info.issuer = $cert.Issuer + $cert_info.valid_from = (ConvertTo-Timestamp -start_date $epoch_date -end_date $cert.NotBefore.ToUniversalTime()) + $cert_info.valid_from_iso8601 = Format-Date -date $cert.NotBefore + $cert_info.valid_to = (ConvertTo-Timestamp -start_date $epoch_date -end_date $cert.NotAfter.ToUniversalTime()) + $cert_info.valid_to_iso8601 = Format-Date -date $cert.NotAfter + $cert_info.serial_number = $cert.SerialNumber + $cert_info.archived = $cert.Archived + $cert_info.version = $cert.Version + $cert_info.has_private_key = $cert.HasPrivateKey + $cert_info.issued_by = $cert.GetNameInfo('SimpleName', $true) + $cert_info.issued_to = $cert.GetNameInfo('SimpleName', $false) + $cert_info.signature_algorithm = $cert.SignatureAlgorithm.FriendlyName + $cert_info.dns_names = [System.Collections.Generic.List`1[String]]@($cert_info.issued_to) + $cert_info.raw = [System.Convert]::ToBase64String($cert.GetRawCertData()) + $cert_info.public_key = [System.Convert]::ToBase64String($cert.GetPublicKey()) + if ($cert.Extensions.Count -gt 0) { + [array]$cert_info.extensions = foreach ($extension in $cert.Extensions) { + $extension_info = @{ + critical = $extension.Critical + field = $extension.Oid.FriendlyName + value = $extension.Format($false) + } + if ($extension -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]) { + $cert_info.is_ca = $extension.CertificateAuthority + $cert_info.path_length_constraint = $extension.PathLengthConstraint + } + elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + $cert_info.intended_purposes = $extension.EnhancedKeyUsages.FriendlyName -as [string[]] + } + elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]) { + $cert_info.key_usages = $extension.KeyUsages.ToString().Split(',').Trim() -as [string[]] + } + elseif ($extension -is [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]) { + $cert_info.ski = $extension.SubjectKeyIdentifier + } + elseif ($extension.Oid.value -eq '2.5.29.17') { + $sans = $extension.Format($true).Split("`r`n", [System.StringSplitOptions]::RemoveEmptyEntries) + foreach ($san in $sans) { + $san_parts = $san.Split("=") + if ($san_parts.Length -ge 2 -and $san_parts[0].Trim() -eq 'DNS Name') { + $cert_info.dns_names.Add($san_parts[1].Trim()) + } + } + } + $extension_info + } + } + return $cert_info +} + +$store_location_values = ([System.Security.Cryptography.X509Certificates.StoreLocation]).GetEnumValues() | ForEach-Object { $_.ToString() } + +$spec = @{ + options = @{ + thumbprint = @{ type = "str"; required = $false } + store_name = @{ type = "str"; default = "My"; } + store_location = @{ type = "str"; default = "LocalMachine"; choices = $store_location_values; } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$thumbprint = $module.Params.thumbprint +$store_name = $module.Params.store_name +$store_location = [System.Security.Cryptography.X509Certificates.Storelocation]"$($module.Params.store_location)" + +$store = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store -ArgumentList $store_name, $store_location +$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + +$module.Result.exists = $false +$module.Result.certificates = @() + +try { + if ($null -ne $thumbprint) { + $found_certs = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $thumbprint, $false) + } + else { + $found_certs = $store.Certificates + } + + if ($found_certs.Count -gt 0) { + $module.Result.exists = $true + [array]$module.Result.certificates = $found_certs | ForEach-Object { Get-CertificateInfo -cert $_ } | Sort-Object -Property { $_.thumbprint } + } +} +finally { + $store.Close() +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_certificate_info.py b/ansible_collections/community/windows/plugins/modules/win_certificate_info.py new file mode 100644 index 00000000..494a6ef8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_certificate_info.py @@ -0,0 +1,228 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Ansible, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_certificate_info +short_description: Get information on certificates from a Windows Certificate Store +description: +- Returns information about certificates in a Windows Certificate Store. +options: + thumbprint: + description: + - The thumbprint as a hex string of a certificate to find. + - When specified, filters the I(certificates) return value to a single certificate + - See the examples for how to format the thumbprint. + type: str + required: no + store_name: + description: + - The name of the store to search. + - See U(https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.storename) + for a list of built-in store names. + type: str + default: My + store_location: + description: + - The location of the store to search. + type: str + choices: [ CurrentUser, LocalMachine ] + default: LocalMachine +seealso: +- module: ansible.windows.win_certificate_store +author: +- Micah Hunsberger (@mhunsber) +''' + +EXAMPLES = r''' +- name: Obtain information about a particular certificate in the computer's personal store + community.windows.win_certificate_info: + thumbprint: BD7AF104CF1872BDB518D95C9534EA941665FD27 + register: mycert + +# thumbprint can also be lower case +- name: Obtain information about a particular certificate in the computer's personal store + community.windows.win_certificate_info: + thumbprint: bd7af104cf1872bdb518d95c9534ea941665fd27 + register: mycert + +- name: Obtain information about all certificates in the root store + community.windows.win_certificate_info: + store_name: Root + register: ca + +# Import a pfx and then get information on the certificates +- name: Import pfx certificate that is password protected + ansible.windows.win_certificate_store: + path: C:\Temp\cert.pfx + state: present + password: VeryStrongPasswordHere! + become: yes + become_method: runas + register: mycert + +- name: Obtain information on each certificate that was touched + community.windows.win_certificate_info: + thumbprint: "{{ item }}" + register: mycert_stats + loop: "{{ mycert.thumbprints }}" +''' + +RETURN = r''' +exists: + description: + - Whether any certificates were found in the store. + - When I(thumbprint) is specified, returns true only if the certificate mathing the thumbprint exists. + returned: success + type: bool + sample: true +certificates: + description: + - A list of information about certificates found in the store, sorted by thumbprint. + returned: success + type: list + elements: dict + contains: + archived: + description: Indicates that the certificate is archived. + type: bool + sample: false + dns_names: + description: Lists the registered dns names for the certificate. + type: list + elements: str + sample: [ '*.m.wikiquote.org', '*.wikipedia.org' ] + extensions: + description: The collection of the certificates extensions. + type: list + elements: dict + sample: [ + { + "critical": false, + "field": "Subject Key Identifier", + "value": "88 27 17 09 a9 b6 18 60 8b ec eb ba f6 47 59 c5 52 54 a3 b7" + }, + { + "critical": true, + "field": "Basic Constraints", + "value": "Subject Type=CA, Path Length Constraint=None" + }, + { + "critical": false, + "field": "Authority Key Identifier", + "value": "KeyID=2b d0 69 47 94 76 09 fe f4 6b 8d 2e 40 a6 f7 47 4d 7f 08 5e" + }, + { + "critical": false, + "field": "CRL Distribution Points", + "value": "[1]CRL Distribution Point: Distribution Point Name:Full Name:URL=http://crl.apple.com/root.crl" + }, + { + "critical": true, + "field": "Key Usage", + "value": "Digital Signature, Certificate Signing, Off-line CRL Signing, CRL Signing (86)" + }, + { + "critical": false, + "field": null, + "value": "05 00" + } + ] + friendly_name: + description: The associated alias for the certificate. + type: str + sample: Microsoft Root Authority + has_private_key: + description: Indicates that the certificate contains a private key. + type: bool + sample: false + intended_purposes: + description: lists the intended applications for the certificate. + returned: enhanced key usages extension exists. + type: list + sample: [ "Server Authentication" ] + is_ca: + description: Indicates that the certificate is a certificate authority (CA) certificate. + returned: basic constraints extension exists. + type: bool + sample: true + issued_by: + description: The certificate issuer's common name. + type: str + sample: Apple Root CA + issued_to: + description: The certificate's common name. + type: str + sample: Apple Worldwide Developer Relations Certification Authority + issuer: + description: The certificate issuer's distinguished name. + type: str + sample: 'CN=Apple Root CA, OU=Apple Certification Authority, O=Apple Inc., C=US' + key_usages: + description: + - Defines how the certificate key can be used. + - If this value is not defined, the key can be used for any purpose. + returned: key usages extension exists. + type: list + elements: str + sample: [ "CrlSign", "KeyCertSign", "DigitalSignature" ] + path_length_constraint: + description: + - The number of levels allowed in a certificates path. + - If this value is 0, the certificate does not have a restriction. + returned: basic constraints extension exists + type: int + sample: 0 + public_key: + description: The base64 encoded public key of the certificate. + type: str + cert_data: + description: The base64 encoded data of the entire certificate. + type: str + serial_number: + description: The serial number of the certificate represented as a hexadecimal string + type: str + sample: 01DEBCC4396DA010 + signature_algorithm: + description: The algorithm used to create the certificate's signature + type: str + sample: sha1RSA + ski: + description: The certificate's subject key identifier + returned: subject key identifier extension exists. + type: str + sample: 88271709A9B618608BECEBBAF64759C55254A3B7 + subject: + description: The certificate's distinguished name. + type: str + sample: 'CN=Apple Worldwide Developer Relations Certification Authority, OU=Apple Worldwide Developer Relations, O=Apple Inc., C=US' + thumbprint: + description: + - The thumbprint as a hex string of the certificate. + - The return format will always be upper case. + type: str + sample: FF6797793A3CD798DC5B2ABEF56F73EDC9F83A64 + valid_from: + description: The start date of the certificate represented in seconds since epoch. + type: float + sample: 1360255727 + valid_from_iso8601: + description: The start date of the certificate represented as an iso8601 formatted date. + type: str + sample: '2017-12-15T08:39:32Z' + valid_to: + description: The expiry date of the certificate represented in seconds since epoch. + type: float + sample: 1675788527 + valid_to_iso8601: + description: The expiry date of the certificate represented as an iso8601 formatted date. + type: str + sample: '2086-01-02T08:39:32Z' + version: + description: The x509 format version of the certificate + type: int + sample: 3 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_computer_description.ps1 b/ansible_collections/community/windows/plugins/modules/win_computer_description.ps1 new file mode 100644 index 00000000..445e5c2a --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_computer_description.ps1 @@ -0,0 +1,54 @@ +#!powershell + +# Copyright: (c) 2019, RusoSova +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.1 + +$spec = @{ + options = @{ + owner = @{ type = "str" } + organization = @{ type = "str" } + description = @{ type = "str" } + } + required_one_of = @( + , @('owner', 'organization', 'description') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$owner = $module.Params.owner +$organization = $module.Params.organization +$description = $module.Params.description +$regPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\" + +#Change description +if ($description -or $description -eq "") { + $descriptionObject = Get-CimInstance -class "Win32_OperatingSystem" + if ($description -cne $descriptionObject.description) { + Set-CimInstance -InputObject $descriptionObject -Property @{"Description" = "$description" } -WhatIf:$module.CheckMode + $module.Result.changed = $true + } +} + +#Change owner +if ($owner -or $owner -eq "") { + $curentOwner = (Get-ItemProperty -LiteralPath $regPath -Name RegisteredOwner).RegisteredOwner + if ($curentOwner -cne $owner) { + Set-ItemProperty -LiteralPath $regPath -Name "RegisteredOwner" -Value $owner -WhatIf:$module.CheckMode + $module.Result.changed = $true + } +} + +#Change organization +if ($organization -or $organization -eq "") { + $curentOrganization = (Get-ItemProperty -LiteralPath $regPath -Name RegisteredOrganization).RegisteredOrganization + if ($curentOrganization -cne $organization) { + Set-ItemProperty -LiteralPath $regPath -Name "RegisteredOrganization" -Value $organization -WhatIf:$module.CheckMode + $module.Result.changed = $true + } +} +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_computer_description.py b/ansible_collections/community/windows/plugins/modules/win_computer_description.py new file mode 100644 index 00000000..48eff886 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_computer_description.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, RusoSova +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_computer_description +short_description: Set windows description, owner and organization +description: + - This module sets Windows description that is shown under My Computer properties. Module also sets + Windows license owner and organization. License information can be viewed by running winver commad. +options: + description: + description: + - String value to apply to Windows descripton. Specify value of "" to clear the value. + required: false + type: str + organization: + description: + - String value of organization that the Windows is licensed to. Specify value of "" to clear the value. + required: false + type: str + owner: + description: + - String value of the persona that the Windows is licensed to. Specify value of "" to clear the value. + required: false + type: str +author: + - RusoSova (@RusoSova) +''' + +EXAMPLES = r''' +- name: Set Windows description, owner and organization + community.windows.win_computer_description: + description: Best Box + owner: RusoSova + organization: MyOrg + register: result + +- name: Set Windows description only + community.windows.win_computer_description: + description: This is my Windows machine + register: result + +- name: Set organization and clear owner field + community.windows.win_computer_description: + owner: '' + organization: Black Mesa + +- name: Clear organization, description and owner + community.windows.win_computer_description: + organization: "" + owner: "" + description: "" + register: result +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_credential.ps1 b/ansible_collections/community/windows/plugins/modules/win_credential.ps1 new file mode 100644 index 00000000..b39bff57 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_credential.ps1 @@ -0,0 +1,724 @@ +#!powershell + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + alias = @{ type = "str" } + attributes = @{ + type = "list" + elements = "dict" + options = @{ + name = @{ type = "str"; required = $true } + data = @{ type = "str" } + data_format = @{ type = "str"; default = "text"; choices = @("base64", "text") } + } + } + comment = @{ type = "str" } + name = @{ type = "str"; required = $true } + persistence = @{ type = "str"; default = "local"; choices = @("enterprise", "local") } + secret = @{ type = "str"; no_log = $true } + secret_format = @{ type = "str"; default = "text"; choices = @("base64", "text") } + state = @{ type = "str"; default = "present"; choices = @("absent", "present") } + type = @{ + type = "str" + required = $true + choices = @("domain_password", "domain_certificate", "generic_password", "generic_certificate") + } + update_secret = @{ type = "str"; default = "always"; choices = @("always", "on_create") } + username = @{ type = "str" } + } + required_if = @( + , @("state", "present", @("username")) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$alias = $module.Params.alias +$attributes = $module.Params.attributes +$comment = $module.Params.comment +$name = $module.Params.name +$persistence = $module.Params.persistence +$secret = $module.Params.secret +$secret_format = $module.Params.secret_format +$state = $module.Params.state +$type = $module.Params.type +$update_secret = $module.Params.update_secret +$username = $module.Params.username + +$module.Diff.before = "" +$module.Diff.after = "" + +Add-CSharpType -AnsibleModule $module -References @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ansible.CredentialManager +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class CREDENTIAL + { + public CredentialFlags Flags; + public CredentialType Type; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + public FILETIME LastWritten; + public UInt32 CredentialBlobSize; + public IntPtr CredentialBlob; + public CredentialPersist Persist; + public UInt32 AttributeCount; + public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + + public static explicit operator Credential(CREDENTIAL v) + { + byte[] secret = new byte[(int)v.CredentialBlobSize]; + if (v.CredentialBlob != IntPtr.Zero) + Marshal.Copy(v.CredentialBlob, secret, 0, secret.Length); + + List<CredentialAttribute> attributes = new List<CredentialAttribute>(); + if (v.AttributeCount > 0) + { + CREDENTIAL_ATTRIBUTE[] rawAttributes = new CREDENTIAL_ATTRIBUTE[v.AttributeCount]; + Credential.PtrToStructureArray(rawAttributes, v.Attributes); + attributes = rawAttributes.Select(x => (CredentialAttribute)x).ToList(); + } + + string userName = v.UserName; + if (v.Type == CredentialType.DomainCertificate || v.Type == CredentialType.GenericCertificate) + userName = Credential.UnmarshalCertificateCredential(userName); + + return new Credential + { + Type = v.Type, + TargetName = v.TargetName, + Comment = v.Comment, + LastWritten = (DateTimeOffset)v.LastWritten, + Secret = secret, + Persist = v.Persist, + Attributes = attributes, + TargetAlias = v.TargetAlias, + UserName = userName, + Loaded = true, + }; + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct CREDENTIAL_ATTRIBUTE + { + [MarshalAs(UnmanagedType.LPWStr)] public string Keyword; + public UInt32 Flags; // Set to 0 and is reserved + public UInt32 ValueSize; + public IntPtr Value; + + public static explicit operator CredentialAttribute(CREDENTIAL_ATTRIBUTE v) + { + byte[] value = new byte[v.ValueSize]; + Marshal.Copy(v.Value, value, 0, (int)v.ValueSize); + + return new CredentialAttribute + { + Keyword = v.Keyword, + Flags = v.Flags, + Value = value, + }; + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct FILETIME + { + internal UInt32 dwLowDateTime; + internal UInt32 dwHighDateTime; + + public static implicit operator long(FILETIME v) { return ((long)v.dwHighDateTime << 32) + v.dwLowDateTime; } + public static explicit operator DateTimeOffset(FILETIME v) { return DateTimeOffset.FromFileTime(v); } + public static explicit operator FILETIME(DateTimeOffset v) + { + return new FILETIME() + { + dwLowDateTime = (UInt32)v.ToFileTime(), + dwHighDateTime = ((UInt32)v.ToFileTime() >> 32), + }; + } + } + + [Flags] + public enum CredentialCreateFlags : uint + { + PreserveCredentialBlob = 1, + } + + [Flags] + public enum CredentialFlags + { + None = 0, + PromptNow = 2, + UsernameTarget = 4, + } + + public enum CredMarshalType : uint + { + CertCredential = 1, + UsernameTargetCredential, + BinaryBlobCredential, + UsernameForPackedCredential, + BinaryBlobForSystem, + } + } + + internal class NativeMethods + { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredDeleteW( + [MarshalAs(UnmanagedType.LPWStr)] string TargetName, + CredentialType Type, + UInt32 Flags); + + [DllImport("advapi32.dll")] + public static extern void CredFree( + IntPtr Buffer); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredMarshalCredentialW( + NativeHelpers.CredMarshalType CredType, + SafeMemoryBuffer Credential, + out SafeCredentialBuffer MarshaledCredential); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredReadW( + [MarshalAs(UnmanagedType.LPWStr)] string TargetName, + CredentialType Type, + UInt32 Flags, + out SafeCredentialBuffer Credential); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredUnmarshalCredentialW( + [MarshalAs(UnmanagedType.LPWStr)] string MarshaledCredential, + out NativeHelpers.CredMarshalType CredType, + out SafeCredentialBuffer Credential); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredWriteW( + NativeHelpers.CREDENTIAL Credential, + NativeHelpers.CredentialCreateFlags Flags); + } + + internal class SafeCredentialBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeCredentialBuffer() : base(true) { } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + NativeMethods.CredFree(handle); + return true; + } + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _exception_msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _exception_msg = String.Format("{0} - {1} (Win32 Error Code {2}: 0x{3})", message, base.Message, errorCode, errorCode.ToString("X8")); + } + public override string Message { get { return _exception_msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public enum CredentialPersist + { + Session = 1, + LocalMachine = 2, + Enterprise = 3, + } + + public enum CredentialType + { + Generic = 1, + DomainPassword = 2, + DomainCertificate = 3, + DomainVisiblePassword = 4, + GenericCertificate = 5, + DomainExtended = 6, + Maximum = 7, + MaximumEx = 1007, + } + + public class CredentialAttribute + { + public string Keyword; + public UInt32 Flags; + public byte[] Value; + } + + public class Credential + { + public CredentialType Type; + public string TargetName; + public string Comment; + public DateTimeOffset LastWritten; + public byte[] Secret; + public CredentialPersist Persist; + public List<CredentialAttribute> Attributes = new List<CredentialAttribute>(); + public string TargetAlias; + public string UserName; + + // Used to track whether the credential has been loaded into the store or not + public bool Loaded { get; internal set; } + + public void Delete() + { + if (!Loaded) + return; + + if (!NativeMethods.CredDeleteW(TargetName, Type, 0)) + throw new Win32Exception(String.Format("CredDeleteW({0}) failed", TargetName)); + Loaded = false; + } + + public void Write(bool preserveExisting) + { + string userName = UserName; + // Convert the certificate thumbprint to the string expected + if (Type == CredentialType.DomainCertificate || Type == CredentialType.GenericCertificate) + userName = Credential.MarshalCertificateCredential(userName); + + NativeHelpers.CREDENTIAL credential = new NativeHelpers.CREDENTIAL + { + Flags = NativeHelpers.CredentialFlags.None, + Type = Type, + TargetName = TargetName, + Comment = Comment, + LastWritten = new NativeHelpers.FILETIME(), + CredentialBlobSize = (UInt32)(Secret == null ? 0 : Secret.Length), + CredentialBlob = IntPtr.Zero, // Must be allocated and freed outside of this to ensure no memory leaks + Persist = Persist, + AttributeCount = (UInt32)(Attributes.Count), + Attributes = IntPtr.Zero, // Attributes must be allocated and freed outside of this to ensure no memory leaks + TargetAlias = TargetAlias, + UserName = userName, + }; + + using (SafeMemoryBuffer credentialBlob = new SafeMemoryBuffer((int)credential.CredentialBlobSize)) + { + if (Secret != null) + Marshal.Copy(Secret, 0, credentialBlob.DangerousGetHandle(), Secret.Length); + credential.CredentialBlob = credentialBlob.DangerousGetHandle(); + + // Store the CREDENTIAL_ATTRIBUTE value in a safe memory buffer and make sure we dispose in all cases + List<SafeMemoryBuffer> attributeBuffers = new List<SafeMemoryBuffer>(); + try + { + int attributeLength = Attributes.Sum(a => Marshal.SizeOf(typeof(NativeHelpers.CREDENTIAL_ATTRIBUTE))); + byte[] attributeBytes = new byte[attributeLength]; + int offset = 0; + foreach (CredentialAttribute attribute in Attributes) + { + SafeMemoryBuffer attributeBuffer = new SafeMemoryBuffer(attribute.Value.Length); + attributeBuffers.Add(attributeBuffer); + if (attribute.Value != null) + Marshal.Copy(attribute.Value, 0, attributeBuffer.DangerousGetHandle(), attribute.Value.Length); + + NativeHelpers.CREDENTIAL_ATTRIBUTE credentialAttribute = new NativeHelpers.CREDENTIAL_ATTRIBUTE + { + Keyword = attribute.Keyword, + Flags = attribute.Flags, + ValueSize = (UInt32)(attribute.Value == null ? 0 : attribute.Value.Length), + Value = attributeBuffer.DangerousGetHandle(), + }; + int attributeStructLength = Marshal.SizeOf(typeof(NativeHelpers.CREDENTIAL_ATTRIBUTE)); + + byte[] attrBytes = new byte[attributeStructLength]; + using (SafeMemoryBuffer tempBuffer = new SafeMemoryBuffer(attributeStructLength)) + { + Marshal.StructureToPtr(credentialAttribute, tempBuffer.DangerousGetHandle(), false); + Marshal.Copy(tempBuffer.DangerousGetHandle(), attrBytes, 0, attributeStructLength); + } + Buffer.BlockCopy(attrBytes, 0, attributeBytes, offset, attributeStructLength); + offset += attributeStructLength; + } + + using (SafeMemoryBuffer attributes = new SafeMemoryBuffer(attributeBytes.Length)) + { + if (attributeBytes.Length != 0) + { + Marshal.Copy(attributeBytes, 0, attributes.DangerousGetHandle(), attributeBytes.Length); + credential.Attributes = attributes.DangerousGetHandle(); + } + + NativeHelpers.CredentialCreateFlags createFlags = 0; + if (preserveExisting) + createFlags |= NativeHelpers.CredentialCreateFlags.PreserveCredentialBlob; + + if (!NativeMethods.CredWriteW(credential, createFlags)) + throw new Win32Exception(String.Format("CredWriteW({0}) failed", TargetName)); + } + } + finally + { + foreach (SafeMemoryBuffer attributeBuffer in attributeBuffers) + attributeBuffer.Dispose(); + } + } + Loaded = true; + } + + public static Credential GetCredential(string target, CredentialType type) + { + SafeCredentialBuffer buffer; + if (!NativeMethods.CredReadW(target, type, 0, out buffer)) + { + int lastErr = Marshal.GetLastWin32Error(); + + // Not running with Become so cannot manage the user's credentials + if (lastErr == 0x00000520) // ERROR_NO_SUCH_LOGON_SESSION + throw new InvalidOperationException("Failed to access the user's credential store, run the module with become"); + else if (lastErr == 0x00000490) // ERROR_NOT_FOUND + return null; + throw new Win32Exception(lastErr, "CredEnumerateW() failed"); + } + + using (buffer) + { + NativeHelpers.CREDENTIAL credential = (NativeHelpers.CREDENTIAL)Marshal.PtrToStructure( + buffer.DangerousGetHandle(), typeof(NativeHelpers.CREDENTIAL)); + return (Credential)credential; + } + } + + public static string MarshalCertificateCredential(string thumbprint) + { + // CredWriteW requires the UserName field to be the value of CredMarshalCredentialW() when writting a + // certificate auth. This converts the UserName property to the format required. + + // While CERT_CREDENTIAL_INFO is the correct structure, we manually marshal the data in order to + // support different cert hash lengths in the future. + // https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_cert_credential_info + int hexLength = thumbprint.Length; + byte[] credInfo = new byte[sizeof(UInt32) + (hexLength / 2)]; + + // First field is cbSize which is a UInt32 value denoting the size of the total structure + Array.Copy(BitConverter.GetBytes((UInt32)credInfo.Length), credInfo, sizeof(UInt32)); + + // Now copy the byte representation of the thumbprint to the rest of the struct bytes + for (int i = 0; i < hexLength; i += 2) + credInfo[sizeof(UInt32) + (i / 2)] = Convert.ToByte(thumbprint.Substring(i, 2), 16); + + IntPtr pCredInfo = Marshal.AllocHGlobal(credInfo.Length); + Marshal.Copy(credInfo, 0, pCredInfo, credInfo.Length); + SafeMemoryBuffer pCredential = new SafeMemoryBuffer(pCredInfo); + + NativeHelpers.CredMarshalType marshalType = NativeHelpers.CredMarshalType.CertCredential; + using (pCredential) + { + SafeCredentialBuffer marshaledCredential; + if (!NativeMethods.CredMarshalCredentialW(marshalType, pCredential, out marshaledCredential)) + throw new Win32Exception("CredMarshalCredentialW() failed"); + using (marshaledCredential) + return Marshal.PtrToStringUni(marshaledCredential.DangerousGetHandle()); + } + } + + public static string UnmarshalCertificateCredential(string value) + { + NativeHelpers.CredMarshalType credType; + SafeCredentialBuffer pCredInfo; + if (!NativeMethods.CredUnmarshalCredentialW(value, out credType, out pCredInfo)) + throw new Win32Exception("CredUnmarshalCredentialW() failed"); + + using (pCredInfo) + { + if (credType != NativeHelpers.CredMarshalType.CertCredential) + throw new InvalidOperationException(String.Format("Expected unmarshalled cred type of CertCredential, received {0}", credType)); + + byte[] structSizeBytes = new byte[sizeof(UInt32)]; + Marshal.Copy(pCredInfo.DangerousGetHandle(), structSizeBytes, 0, sizeof(UInt32)); + UInt32 structSize = BitConverter.ToUInt32(structSizeBytes, 0); + + byte[] certInfoBytes = new byte[structSize]; + Marshal.Copy(pCredInfo.DangerousGetHandle(), certInfoBytes, 0, certInfoBytes.Length); + + StringBuilder hex = new StringBuilder((certInfoBytes.Length - sizeof(UInt32)) * 2); + for (int i = 4; i < certInfoBytes.Length; i++) + hex.AppendFormat("{0:x2}", certInfoBytes[i]); + + return hex.ToString().ToUpperInvariant(); + } + } + + internal static void PtrToStructureArray<T>(T[] array, IntPtr ptr) + { + IntPtr ptrOffset = ptr; + for (int i = 0; i < array.Length; i++, ptrOffset = IntPtr.Add(ptrOffset, Marshal.SizeOf(typeof(T)))) + array[i] = (T)Marshal.PtrToStructure(ptrOffset, typeof(T)); + } + } +} +'@ + +Function ConvertTo-CredentialAttribute { + param($Attributes) + + $converted_attributes = [System.Collections.Generic.List`1[Ansible.CredentialManager.CredentialAttribute]]@() + foreach ($attribute in $Attributes) { + $new_attribute = New-Object -TypeName Ansible.CredentialManager.CredentialAttribute + $new_attribute.Keyword = $attribute.name + + if ($null -ne $attribute.data) { + if ($attribute.data_format -eq "base64") { + $new_attribute.Value = [System.Convert]::FromBase64String($attribute.data) + } + else { + $new_attribute.Value = [System.Text.Encoding]::UTF8.GetBytes($attribute.data) + } + } + $converted_attributes.Add($new_attribute) > $null + } + + return , $converted_attributes +} + +Function Get-DiffInfo { + param($AnsibleCredential) + + $diff = @{ + alias = $AnsibleCredential.TargetAlias + attributes = [System.Collections.ArrayList]@() + comment = $AnsibleCredential.Comment + name = $AnsibleCredential.TargetName + persistence = $AnsibleCredential.Persist.ToString() + type = $AnsibleCredential.Type.ToString() + username = $AnsibleCredential.UserName + } + + foreach ($attribute in $AnsibleCredential.Attributes) { + $attribute_info = @{ + name = $attribute.Keyword + data = $null + } + if ($null -ne $attribute.Value) { + $attribute_info.data = [System.Convert]::ToBase64String($attribute.Value) + } + $diff.attributes.Add($attribute_info) > $null + } + + return , $diff +} + +# If the username is a certificate thumbprint, verify it's a valid cert in the CurrentUser/Personal store +if ($null -ne $username -and $type -in @("domain_certificate", "generic_certificate")) { + # Ensure the thumbprint is upper case with no spaces or hyphens + $username = $username.ToUpperInvariant().Replace(" ", "").Replace("-", "") + + $certificate = Get-Item -LiteralPath Cert:\CurrentUser\My\$username -ErrorAction SilentlyContinue + if ($null -eq $certificate) { + $module.FailJson("Failed to find certificate with the thumbprint $username in the CurrentUser\My store") + } +} + +# Convert the input secret to a byte array +if ($null -ne $secret) { + if ($secret_format -eq "base64") { + $secret = [System.Convert]::FromBase64String($secret) + } + else { + $secret = [System.Text.Encoding]::Unicode.GetBytes($secret) + } +} + +$persistence = switch ($persistence) { + "local" { [Ansible.CredentialManager.CredentialPersist]::LocalMachine } + "enterprise" { [Ansible.CredentialManager.CredentialPersist]::Enterprise } +} + +$type = switch ($type) { + "domain_password" { [Ansible.CredentialManager.CredentialType]::DomainPassword } + "domain_certificate" { [Ansible.CredentialManager.CredentialType]::DomainCertificate } + "generic_password" { [Ansible.CredentialManager.CredentialType]::Generic } + "generic_certificate" { [Ansible.CredentialManager.CredentialType]::GenericCertificate } +} + +$existing_credential = [Ansible.CredentialManager.Credential]::GetCredential($name, $type) +if ($null -ne $existing_credential) { + $module.Diff.before = Get-DiffInfo -AnsibleCredential $existing_credential +} + +if ($state -eq "absent") { + if ($null -ne $existing_credential) { + if (-not $module.CheckMode) { + $existing_credential.Delete() + } + $module.Result.changed = $true + } +} +else { + if ($null -eq $existing_credential) { + $new_credential = New-Object -TypeName Ansible.CredentialManager.Credential + $new_credential.Type = $type + $new_credential.TargetName = $name + $new_credential.Comment = if ($comment) { $comment } else { [NullString]::Value } + $new_credential.Secret = $secret + $new_credential.Persist = $persistence + $new_credential.TargetAlias = if ($alias) { $alias } else { [NullString]::Value } + $new_credential.UserName = $username + + if ($null -ne $attributes) { + $new_credential.Attributes = ConvertTo-CredentialAttribute -Attributes $attributes + } + + if (-not $module.CheckMode) { + $new_credential.Write($false) + } + $module.Result.changed = $true + } + else { + $changed = $false + $preserve_blob = $false + + # make sure we do case comparison for the comment + if ($existing_credential.Comment -cne $comment) { + $existing_credential.Comment = $comment + $changed = $true + } + + if ($existing_credential.Persist -ne $persistence) { + $existing_credential.Persist = $persistence + $changed = $true + } + + if ($existing_credential.TargetAlias -ne $alias) { + $existing_credential.TargetAlias = $alias + $changed = $true + } + + if ($existing_credential.UserName -ne $username) { + $existing_credential.UserName = $username + $changed = $true + } + + if ($null -ne $attributes) { + $attribute_changed = $false + + $new_attributes = ConvertTo-CredentialAttribute -Attributes $attributes + if ($new_attributes.Count -ne $existing_credential.Attributes.Count) { + $attribute_changed = $true + } + else { + for ($i = 0; $i -lt $new_attributes.Count; $i++) { + $new_keyword = $new_attributes[$i].Keyword + $new_value = $new_attributes[$i].Value + if ($null -eq $new_value) { + $new_value = "" + } + else { + $new_value = [System.Convert]::ToBase64String($new_value) + } + + $existing_keyword = $existing_credential.Attributes[$i].Keyword + $existing_value = $existing_credential.Attributes[$i].Value + if ($null -eq $existing_value) { + $existing_value = "" + } + else { + $existing_value = [System.Convert]::ToBase64String($existing_value) + } + + if (($new_keyword -cne $existing_keyword) -or ($new_value -ne $existing_value)) { + $attribute_changed = $true + break + } + } + } + + if ($attribute_changed) { + $existing_credential.Attributes = $new_attributes + $changed = $true + } + } + + if ($null -eq $secret) { + # If we haven't explicitly set a secret, tell Windows to preserve the existing blob + $preserve_blob = $true + $existing_credential.Secret = $null + } + elseif ($update_secret -eq "always") { + # We should only set the password if we can't read the existing one or it doesn't match our secret + if ($existing_credential.Secret.Length -eq 0) { + # We cannot read the secret so don't know if its the configured secret + $existing_credential.Secret = $secret + $changed = $true + } + else { + # We can read the secret so compare with our input + $input_secret_b64 = [System.Convert]::ToBase64String($secret) + $actual_secret_b64 = [System.Convert]::ToBase64String($existing_credential.Secret) + if ($input_secret_b64 -ne $actual_secret_b64) { + $existing_credential.Secret = $secret + $changed = $true + } + } + } + + if ($changed -and -not $module.CheckMode) { + $existing_credential.Write($preserve_blob) + } + $module.Result.changed = $changed + } + + if ($module.CheckMode) { + # We cannot reliably get the credential in check mode, set it based on the input + $module.Diff.after = @{ + alias = $alias + attributes = $attributes + comment = $comment + name = $name + persistence = $persistence.ToString() + type = $type.ToString() + username = $username + } + } + else { + # Get a new copy of the credential and use that to set the after diff + $new_credential = [Ansible.CredentialManager.Credential]::GetCredential($name, $type) + $module.Diff.after = Get-DiffInfo -AnsibleCredential $new_credential + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_credential.py b/ansible_collections/community/windows/plugins/modules/win_credential.py new file mode 100644 index 00000000..fd605f0d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_credential.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_credential +short_description: Manages Windows Credentials in the Credential Manager +description: +- Used to create and remove Windows Credentials in the Credential Manager. +- This module can manage both standard username/password credentials as well as + certificate credentials. +options: + alias: + description: + - Adds an alias for the credential. + - Typically this is the NetBIOS name of a host if I(name) is set to the DNS + name. + type: str + attributes: + description: + - A list of dicts that set application specific attributes for a + credential. + - When set, existing attributes will be compared to the list as a whole, + any differences means all attributes will be replaced. + type: list + elements: dict + suboptions: + name: + description: + - The key for the attribute. + - This is not a unique identifier as multiple attributes can have the + same key. + type: str + required: true + data: + description: + - The value for the attribute. + type: str + data_format: + description: + - Controls the input type for I(data). + - If C(text), I(data) is a text string that is UTF-16LE encoded to + bytes. + - If C(base64), I(data) is a base64 string that is base64 decoded to + bytes. + type: str + choices: [ base64, text ] + default: text + comment: + description: + - A user defined comment for the credential. + type: str + name: + description: + - The target that identifies the server or servers that the credential is + to be used for. + - If the value can be a NetBIOS name, DNS server name, DNS host name suffix + with a wildcard character (C(*)), a NetBIOS of DNS domain name that + contains a wildcard character sequence, or an asterisk. + - See C(TargetName) in U(https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentiala) + for more details on what this value can be. + - This is used with I(type) to produce a unique credential. + type: str + required: true + persistence: + description: + - Defines the persistence of the credential. + - If C(local), the credential will persist for all logons of the same user + on the same host. + - C(enterprise) is the same as C(local) but the credential is visible to + the same domain user when running on other hosts and not just localhost. + type: str + choices: [ enterprise, local ] + default: local + secret: + description: + - The secret for the credential. + - When omitted, then no secret is used for the credential if a new + credentials is created. + - When I(type) is a password type, this is the password for I(username). + - When I(type) is a certificate type, this is the pin for the certificate. + type: str + secret_format: + description: + - Controls the input type for I(secret). + - If C(text), I(secret) is a text string that is UTF-16LE encoded to bytes. + - If C(base64), I(secret) is a base64 string that is base64 decoded to + bytes. + type: str + choices: [ base64, text ] + default: text + state: + description: + - When C(absent), the credential specified by I(name) and I(type) is + removed. + - When C(present), the credential specified by I(name) and I(type) is + removed. + type: str + choices: [ absent, present ] + default: present + type: + description: + - The type of credential to store. + - This is used with I(name) to produce a unique credential. + - When the type is a C(domain) type, the credential is used by Microsoft + authentication packages like Negotiate. + - When the type is a C(generic) type, the credential is not used by any + particular authentication package. + - It is recommended to use a C(domain) type as only authentication + providers can access the secret. + type: str + required: true + choices: [ domain_certificate, domain_password, generic_certificate, generic_password ] + update_secret: + description: + - When C(always), the secret will always be updated if they differ. + - When C(on_create), the secret will only be checked/updated when it is + first created. + - If the secret cannot be retrieved and this is set to C(always), the + module will always result in a change. + type: str + choices: [ always, on_create ] + default: always + username: + description: + - When I(type) is a password type, then this is the username to store for + the credential. + - When I(type) is a credential type, then this is the thumbprint as a hex + string of the certificate to use. + - When C(type=domain_password), this should be in the form of a Netlogon + (DOMAIN\Username) or a UPN (username@DOMAIN). + - If using a certificate thumbprint, the certificate must exist in the + C(CurrentUser\My) certificate store for the executing user. + type: str +notes: +- This module requires to be run with C(become) so it can access the + user's credential store. +- There can only be one credential per host and type. if a second credential is + defined that uses the same host and type, then the original credential is + overwritten. +seealso: +- module: ansible.windows.win_user_right +- module: ansible.windows.win_whoami +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create a local only credential + community.windows.win_credential: + name: server.domain.com + type: domain_password + username: DOMAIN\username + secret: Password01 + state: present + +- name: Remove a credential + community.windows.win_credential: + name: server.domain.com + type: domain_password + state: absent + +- name: Create a credential with full values + community.windows.win_credential: + name: server.domain.com + type: domain_password + alias: server + username: username@DOMAIN.COM + secret: Password01 + comment: Credential for server.domain.com + persistence: enterprise + attributes: + - name: Source + data: Ansible + - name: Unique Identifier + data: Y3VzdG9tIGF0dHJpYnV0ZQ== + data_format: base64 + +- name: Create a certificate credential + community.windows.win_credential: + name: '*.domain.com' + type: domain_certificate + username: 0074CC4F200D27DC3877C24A92BA8EA21E6C7AF4 + state: present + +- name: Create a generic credential + community.windows.win_credential: + name: smbhost + type: generic_password + username: smbuser + secret: smbuser + state: present + +- name: Remove a generic credential + community.windows.win_credential: + name: smbhost + type: generic_password + state: absent +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_data_deduplication.ps1 b/ansible_collections/community/windows/plugins/modules/win_data_deduplication.ps1 new file mode 100644 index 00000000..b326ea54 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_data_deduplication.ps1 @@ -0,0 +1,132 @@ +#!powershell + +# Copyright: 2019, rnsc(@rnsc) <github@rnsc.be> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.3 + +$spec = @{ + options = @{ + drive_letter = @{ type = "str"; required = $true } + state = @{ type = "str"; choices = "absent", "present"; default = "present"; } + settings = @{ + type = "dict" + required = $false + options = @{ + minimum_file_size = @{ type = "int"; default = 32768 } + minimum_file_age_days = @{ type = "int"; default = 2 } + no_compress = @{ type = "bool"; required = $false; default = $false } + optimize_in_use_files = @{ type = "bool"; required = $false; default = $false } + verify = @{ type = "bool"; required = $false; default = $false } + } + } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$drive_letter = $module.Params.drive_letter +$state = $module.Params.state +$settings = $module.Params.settings + +$module.Result.changed = $false +$module.Result.reboot_required = $false +$module.Result.msg = "" + +function Set-DataDeduplication($volume, $state, $settings, $dedup_job) { + + $current_state = 'absent' + + try { + $dedup_info = Get-DedupVolume -Volume "$($volume.DriveLetter):" + } + catch { + $dedup_info = $null + } + + if ($dedup_info.Enabled) { + $current_state = 'present' + } + + if ( $state -ne $current_state ) { + if ( -not $module.CheckMode) { + if ($state -eq 'present') { + # Enable-DedupVolume -Volume <String> + Enable-DedupVolume -Volume "$($volume.DriveLetter):" + } + elseif ($state -eq 'absent') { + Disable-DedupVolume -Volume "$($volume.DriveLetter):" + } + } + $module.Result.changed = $true + } + + if ($state -eq 'present') { + if ($null -ne $settings) { + Set-DataDedupJobSetting -volume $volume -settings $settings + } + } +} + +function Set-DataDedupJobSetting ($volume, $settings) { + + try { + $dedup_info = Get-DedupVolume -Volume "$($volume.DriveLetter):" + } + catch { + $dedup_info = $null + } + + ForEach ($key in $settings.keys) { + + # See Microsoft documentation: + # https://docs.microsoft.com/en-us/powershell/module/deduplication/set-dedupvolume?view=win10-ps + + $update_key = $key + $update_value = $settings.$($key) + # Transform Ansible style options to Powershell params + $update_key = $update_key -replace ('_', '') + + if ($update_key -eq "MinimumFileSize" -and $update_value -lt 32768) { + $update_value = 32768 + } + + $current_value = ($dedup_info | Select-Object -ExpandProperty $update_key) + + if ($update_value -ne $current_value) { + $command_param = @{ + $($update_key) = $update_value + } + + # Set-DedupVolume -Volume <String>` + # -NoCompress <bool> ` + # -MinimumFileAgeDays <UInt32> ` + # -MinimumFileSize <UInt32> (minimum 32768) + if ( -not $module.CheckMode ) { + Set-DedupVolume -Volume "$($volume.DriveLetter):" @command_param + } + + $module.Result.changed = $true + } + } + +} + +# Install required feature +$feature_name = "FS-Data-Deduplication" +if ( -not $module.CheckMode) { + $feature = Install-WindowsFeature -Name $feature_name + + if ($feature.RestartNeeded -eq 'Yes') { + $module.Result.reboot_required = $true + $module.FailJson("$feature_name was installed but requires Windows to be rebooted to work.") + } +} + +$volume = Get-Volume -DriveLetter $drive_letter + +Set-DataDeduplication -volume $volume -state $state -settings $settings -dedup_job $dedup_job + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_data_deduplication.py b/ansible_collections/community/windows/plugins/modules/win_data_deduplication.py new file mode 100644 index 00000000..f4347462 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_data_deduplication.py @@ -0,0 +1,82 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 2019, rnsc(@rnsc) <github@rnsc.be> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_data_deduplication +short_description: Module to enable Data Deduplication on a volume. +description: +- This module can be used to enable Data Deduplication on a Windows volume. +- The module will install the FS-Data-Deduplication feature (a reboot will be necessary). +options: + drive_letter: + description: + - Windows drive letter on which to enable data deduplication. + required: yes + type: str + state: + description: + - Wether to enable or disable data deduplication on the selected volume. + default: present + type: str + choices: [ present, absent ] + settings: + description: + - Dictionary of settings to pass to the Set-DedupVolume powershell command. + type: dict + suboptions: + minimum_file_size: + description: + - Minimum file size you want to target for deduplication. + - It will default to 32768 if not defined or if the value is less than 32768. + type: int + default: 32768 + minimum_file_age_days: + description: + - Minimum file age you want to target for deduplication. + type: int + default: 2 + no_compress: + description: + - Wether you want to enabled filesystem compression or not. + type: bool + default: no + optimize_in_use_files: + description: + - Indicates that the server attempts to optimize currently open files. + type: bool + default: no + verify: + description: + - Indicates whether the deduplication engine performs a byte-for-byte verification for each duplicate chunk + that optimization creates, rather than relying on a cryptographically strong hash. + - This option is not recommend. + - Setting this parameter to True can degrade optimization performance. + type: bool + default: no +author: +- rnsc (@rnsc) +''' + +EXAMPLES = r''' +- name: Enable Data Deduplication on D + community.windows.win_data_deduplication: + drive_letter: 'D' + state: present + +- name: Enable Data Deduplication on D + community.windows.win_data_deduplication: + drive_letter: 'D' + state: present + settings: + no_compress: true + minimum_file_age_days: 1 + minimum_file_size: 0 +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_defrag.ps1 b/ansible_collections/community/windows/plugins/modules/win_defrag.ps1 new file mode 100644 index 00000000..067e4908 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_defrag.ps1 @@ -0,0 +1,101 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$spec = @{ + options = @{ + include_volumes = @{ type = 'list'; elements = 'str' } + exclude_volumes = @{ type = 'list'; elements = 'str' } + freespace_consolidation = @{ type = 'bool'; default = $false } + priority = @{ type = 'str'; default = 'low'; choices = @( 'low', 'normal') } + parallel = @{ type = 'bool'; default = $false } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$include_volumes = $module.Params.include_volumes +$exclude_volumes = $module.Params.exclude_volumes +$freespace_consolidation = $module.Params.freespace_consolidation +$priority = $module.Params.priority +$parallel = $module.Params.parallel + +$module.Result.changed = $false + +$executable = "defrag.exe" + +if (-not (Get-Command -Name $executable -ErrorAction SilentlyContinue)) { + $module.FailJson("Command '$executable' not found in $env:PATH.") +} + +$arguments = @() + +if ($include_volumes) { + foreach ($volume in $include_volumes) { + if ($volume.Length -eq 1) { + $arguments += "$($volume):" + } + else { + $arguments += $volume + } + } +} +else { + $arguments += "/C" +} + +if ($exclude_volumes) { + $arguments += "/E" + foreach ($volume in $exclude_volumes) { + if ($volume.Length -eq 1) { + $arguments += "$($volume):" + } + else { + $arguments += $volume + } + } +} + +if ($module.CheckMode) { + $arguments += "/A" +} +elseif ($freespace_consolidation) { + $arguments += "/X" +} + +if ($priority -eq "normal") { + $arguments += "/H" +} + +if ($parallel) { + $arguments += "/M" +} + +$arguments += "/V" + +$argument_string = Argv-ToString -arguments $arguments + +$start_datetime = [DateTime]::UtcNow +$module.Result.cmd = "$executable $argument_string" + +$command_result = Run-Command -command "$executable $argument_string" + +$end_datetime = [DateTime]::UtcNow + +$module.Result.stdout = $command_result.stdout +$module.Result.stderr = $command_result.stderr +$module.Result.rc = $command_result.rc + +$module.Result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +$module.Result.changed = $true + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_defrag.py b/ansible_collections/community/windows/plugins/modules/win_defrag.py new file mode 100644 index 00000000..7a268d50 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_defrag.py @@ -0,0 +1,98 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_defrag +short_description: Consolidate fragmented files on local volumes +description: +- Locates and consolidates fragmented files on local volumes to improve system performance. +- 'More information regarding C(win_defrag) is available from: U(https://technet.microsoft.com/en-us/library/cc731650%28v%3Dws.11.aspx%29)' +requirements: +- defrag.exe +options: + include_volumes: + description: + - A list of drive letters or mount point paths of the volumes to be defragmented. + - If this parameter is omitted, all volumes (not excluded) will be fragmented. + type: list + elements: str + exclude_volumes: + description: + - A list of drive letters or mount point paths to exclude from defragmentation. + type: list + elements: str + freespace_consolidation: + description: + - Perform free space consolidation on the specified volumes. + type: bool + default: no + priority: + description: + - Run the operation at low or normal priority. + type: str + choices: [ low, normal ] + default: low + parallel: + description: + - Run the operation on each volume in parallel in the background. + type: bool + default: no +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Defragment all local volumes (in parallel) + community.windows.win_defrag: + parallel: yes + +- name: 'Defragment all local volumes, except C: and D:' + community.windows.win_defrag: + exclude_volumes: [ C, D ] + +- name: 'Defragment volume D: with normal priority' + community.windows.win_defrag: + include_volumes: D + priority: normal + +- name: Consolidate free space (useful when reducing volumes) + community.windows.win_defrag: + freespace_consolidation: yes +''' + +RETURN = r''' +cmd: + description: The complete command line used by the module. + returned: always + type: str + sample: defrag.exe /C /V +rc: + description: The return code for the command. + returned: always + type: int + sample: 0 +stdout: + description: The standard output from the command. + returned: always + type: str + sample: Success. +stderr: + description: The error output from the command. + returned: always + type: str + sample: +msg: + description: Possible error message on failure. + returned: failed + type: str + sample: Command 'defrag.exe' not found in $env:PATH. +changed: + description: Whether or not any changes were made. + returned: always + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.ps1 b/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.ps1 new file mode 100644 index 00000000..004e16dc --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.ps1 @@ -0,0 +1,446 @@ +#!powershell + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + type = @{ type = "str"; choices = "reservation", "lease"; default = "reservation" } + ip = @{ type = "str" } + scope_id = @{ type = "str" } + mac = @{ type = "str" } + duration = @{ type = "int" } + dns_hostname = @{ type = "str"; } + dns_regtype = @{ type = "str"; choices = "aptr", "a", "noreg"; default = "aptr" } + reservation_name = @{ type = "str"; } + description = @{ type = "str"; } + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + } + required_if = @( + @("state", "present", @("mac", "ip"), $true), + @("state", "absent", @("mac", "ip"), $true) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$check_mode = $module.CheckMode + +$type = $module.Params.type +$ip = $module.Params.ip +$scope_id = $module.Params.scope_id +$mac = $module.Params.mac +$duration = $module.Params.duration +$dns_hostname = $module.Params.dns_hostname +$dns_regtype = $module.Params.dns_regtype +$reservation_name = $module.Params.reservation_name +$description = $module.Params.description +$state = $module.Params.state + +Function Convert-MacAddress { + Param( + [string]$mac + ) + + # Evaluate Length + if ($mac.Length -eq 12) { + # Insert Dashes + $mac = $mac.Insert(2, "-").Insert(5, "-").Insert(8, "-").Insert(11, "-").Insert(14, "-") + return $mac + } + elseif ($mac.Length -eq 17) { + # Replace Colons by Dashes + return ($mac -replace ':', '-') + } + else { + return $false + } +} + +Function Compare-DhcpLease { + Param( + [PSObject]$Original, + [PSObject]$Updated + ) + + # Compare values that we care about + -not ( + ($Original.AddressState -eq $Updated.AddressState) -and + ($Original.IPAddress -eq $Updated.IPAddress) -and + ($Original.ScopeId -eq $Updated.ScopeId) -and + ($Original.Name -eq $Updated.Name) -and + ($Original.Description -eq $Updated.Description) + ) +} + +Function Convert-ReturnValue { + Param( + $Object + ) + + return @{ + address_state = $Object.AddressState + client_id = $Object.ClientId + ip_address = $Object.IPAddress.IPAddressToString + scope_id = $Object.ScopeId.IPAddressToString + name = $Object.Name + description = $Object.Description + } +} + +# Parse Regtype +if ($dns_regtype) { + Switch ($dns_regtype) { + "aptr" { $dns_regtype = "AandPTR"; break } + "a" { $dns_regtype = "A"; break } + "noreg" { $dns_regtype = "NoRegistration"; break } + default { $dns_regtype = "NoRegistration"; break } + } +} + +Try { + # Import DHCP Server PS Module + Import-Module DhcpServer +} +Catch { + # Couldn't load the DhcpServer Module + $module.FailJson("The DhcpServer module failed to load properly: $($_.Exception.Message)", $_) +} + +# Find existing lease by MAC address +if ($mac) { + $mac = Convert-MacAddress -mac $mac + + if ($mac -eq $false) { + $module.FailJson("The MAC Address is not properly formatted") + } + else { + $current_lease = Get-DhcpServerv4Scope | Get-DhcpServerv4Lease | Where-Object ClientId -eq $mac + } +} + +# Find existing lease by IP address +if ($ip -and (-not $current_lease)) { + $current_lease = Get-DhcpServerv4Scope | Get-DhcpServerv4Lease | Where-Object IPAddress -eq $ip +} + +# Did we find a lease/reservation +if ($current_lease) { + $current_lease_exists = $true + $original_lease = $current_lease + $module.Diff.before = Convert-ReturnValue -Object $original_lease +} +else { + $current_lease_exists = $false +} + +# If we found a lease, is it a reservation? +if ($current_lease_exists -eq $true -and ($current_lease.AddressState -like "*Reservation*")) { + $current_lease_reservation = $true +} +else { + $current_lease_reservation = $false +} + +# State: Absent +# Ensure the DHCP Lease/Reservation is not present +if ($state -eq "absent") { + # If the lease doesn't exist, our work here is done + if ($current_lease_exists -eq $false) { + $module.Result.msg = "The lease doesn't exist." + } + else { + # If the lease exists, we need to destroy it + if ($current_lease_reservation -eq $true) { + # Try to remove reservation + Try { + $current_lease | Remove-DhcpServerv4Reservation -WhatIf:$check_mode + $state_absent_removed = $true + } + Catch { + $state_absent_removed = $false + $remove_err = $_ + } + } + else { + # Try to remove lease + Try { + $current_lease | Remove-DhcpServerv4Lease -WhatIf:$check_mode + $state_absent_removed = $true + } + Catch { + $state_absent_removed = $false + $remove_err = $_ + } + } + + # See if we removed the lease/reservation + if ($state_absent_removed) { + $module.Result.changed = $true + } + else { + $module.Result.lease = Convert-ReturnValue -Object $current_lease + $module.FailJson("Unable to remove lease/reservation: $($remove_err.Exception.Message)", $remove_err) + } + } +} + +# State: Present +# Ensure the DHCP Lease/Reservation is present, and consistent +if ($state -eq "present") { + # Current lease exists, and is not a reservation + if (($current_lease_reservation -eq $false) -and ($current_lease_exists -eq $true)) { + if ($type -eq "reservation") { + Try { + # Update parameters + $params = @{ } + + if ($mac) { + $params.ClientId = $mac + } + else { + $params.ClientId = $current_lease.ClientId + } + + if ($description) { + $params.Description = $description + } + else { + $params.Description = $current_lease.Description + } + + if ($reservation_name) { + $params.Name = $reservation_name + } + else { + $params.Name = "reservation-" + $params.ClientId + } + + # Desired type is reservation + $current_lease | Add-DhcpServerv4Reservation -WhatIf:$check_mode + + if (-not $check_mode) { + $current_reservation = Get-DhcpServerv4Lease -ClientId $params.ClientId -ScopeId $current_lease.ScopeId + } + + # Update the reservation with new values + $current_reservation | Set-DhcpServerv4Reservation @params -WhatIf:$check_mode + + if (-not $check_mode) { + $updated_reservation = Get-DhcpServerv4Lease -ClientId $params.ClientId -ScopeId $current_reservation.ScopeId + } + + if (-not $check_mode) { + # Compare Values + $module.Result.changed = Compare-DhcpLease -Original $original_lease -Updated $updated_reservation + $module.Result.lease = Convert-ReturnValue -Object $updated_reservation + } + else { + $module.Result.changed = $true + } + + $module.ExitJson() + } + Catch { + $module.FailJson("Could not convert lease to a reservation", $_) + } + } + } + + # Current lease exists, and is a reservation + if (($current_lease_reservation -eq $true) -and ($current_lease_exists -eq $true)) { + if ($type -eq "lease") { + Try { + # Desired type is a lease, remove the reservation + $current_lease | Remove-DhcpServerv4Reservation -WhatIf:$check_mode + + # Build a new lease object with remnants of the reservation + $lease_params = @{ + ClientId = $original_lease.ClientId + IPAddress = $original_lease.IPAddress.IPAddressToString + ScopeId = $original_lease.ScopeId.IPAddressToString + HostName = $original_lease.HostName + AddressState = 'Active' + } + + # Create new lease + Try { + Add-DhcpServerv4Lease @lease_params -WhatIf:$check_mode + } + Catch { + $module.FailJson("Unable to convert the reservation to a lease", $_) + } + + # Get the lease we just created + if (-not $check_mode) { + Try { + $new_lease = Get-DhcpServerv4Lease -ClientId $lease_params.ClientId -ScopeId $lease_params.ScopeId + } + Catch { + $module.FailJson("Unable to retreive the newly created lease", $_) + } + } + + if (-not $check_mode) { + $module.Result.lease = Convert-ReturnValue -Object $new_lease + } + + $module.Result.changed = $true + $module.ExitJson() + } + Catch { + $module.FailJson("Could not convert reservation to lease", $_) + } + } + + # Already in the desired state + if ($type -eq "reservation") { + + # Update parameters + $params = @{ } + + if ($mac) { + $params.ClientId = $mac + } + else { + $params.ClientId = $current_lease.ClientId + } + + if ($description) { + $params.Description = $description + } + else { + $params.Description = $current_lease.Description + } + + if ($reservation_name) { + $params.Name = $reservation_name + } + else { + # Original lease had a null name so let's generate one + if ($null -eq $original_lease.Name) { + $params.Name = "reservation-" + $original_lease.ClientId + } + else { + $params.Name = $original_lease.Name + } + } + + # Update the reservation with new values + $current_lease | Set-DhcpServerv4Reservation @params -WhatIf:$check_mode + + if (-not $check_mode) { + $reservation = Get-DhcpServerv4Lease -ClientId $current_lease.ClientId -ScopeId $current_lease.ScopeId + $module.Result.changed = Compare-DhcpLease -Original $original_lease -Updated $reservation + $module.Result.lease = Convert-ReturnValue -Object $reservation + } + else { + $module.Result.changed = $true + } + + # Return values + $module.ExitJson() + } + } + + # Lease Doesn't Exist - Create + if ($current_lease_exists -eq $false) { + # Required: Scope ID + if (-not $scope_id) { + $module.Result.changed = $false + $module.FailJson("The scope_id parameter is required for state=present when a lease or reservation doesn't already exist") + } + + # Required Parameters + $lease_params = @{ + ClientId = $mac + IPAddress = $ip + ScopeId = $scope_id + AddressState = 'Active' + Confirm = $false + } + + if ($duration) { + $lease_params.LeaseExpiryTime = (Get-Date).AddDays($duration) + } + + if ($dns_hostname) { + $lease_params.HostName = $dns_hostname + } + + if ($dns_regtype) { + $lease_params.DnsRR = $dns_regtype + } + + if ($description) { + $lease_params.Description = $description + } + + # Create Lease + Try { + # Create lease based on parameters + Add-DhcpServerv4Lease @lease_params -WhatIf:$check_mode + + # Retreive the lease + if (-not $check_mode) { + $new_lease = Get-DhcpServerv4Lease -ClientId $mac -ScopeId $scope_id + $module.Result.lease = Convert-ReturnValue -Object $new_lease + } + + # If lease is the desired type + if ($type -eq "lease") { + $module.Result.changed = $true + $module.ExitJson() + } + } + Catch { + # Failed to create lease + $module.FailJson("Could not create DHCP lease: $($_.Exception.Message)", $_) + } + + # Create Reservation + Try { + # If reservation is the desired type + if ($type -eq "reservation") { + if ($reservation_name) { + $lease_params.Name = $reservation_name + } + else { + $lease_params.Name = "reservation-" + $mac + } + + Try { + if ($check_mode) { + # In check mode, a lease won't exist for conversion, make one manually + Add-DhcpServerv4Reservation -ScopeId $scope_id -ClientId $mac -IPAddress $ip -WhatIf:$check_mode + } + else { + # Convert to Reservation + $new_lease | Add-DhcpServerv4Reservation -WhatIf:$check_mode + } + } + Catch { + # Failed to create reservation + $module.FailJson("Could not create DHCP reservation: $($_.Exception.Message)", $_) + } + + if (-not $check_mode) { + # Get DHCP reservation object + $new_lease = Get-DhcpServerv4Reservation -ClientId $mac -ScopeId $scope_id + $module.Result.lease = Convert-ReturnValue -Object $new_lease + } + + $module.Result.changed = $true + } + } + Catch { + # Failed to create reservation + $module.FailJson("Could not create DHCP reservation: $($_.Exception.Message)", $_) + } + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.py b/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.py new file mode 100644 index 00000000..d70bcf2f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dhcp_lease.py @@ -0,0 +1,135 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_dhcp_lease +short_description: Manage Windows Server DHCP Leases +author: Joe Zollo (@joezollo) +requirements: + - This module requires Windows Server 2012 or Newer +description: + - Manage Windows Server DHCP Leases (IPv4 Only) + - Adds, Removes and Modifies DHCP Leases and Reservations + - Task should be delegated to a Windows DHCP Server +options: + type: + description: + - The type of DHCP address. + - Leases expire as defined by l(duration). + - When l(duration) is not specified, the server default is used. + - Reservations are permanent. + type: str + default: reservation + choices: [ reservation, lease ] + state: + description: + - Specifies the desired state of the DHCP lease or reservation. + type: str + default: present + choices: [ present, absent ] + ip: + description: + - The IPv4 address of the client server/computer. + - This is a required parameter, if l(mac) is not set. + - Can be used to identify an existing lease/reservation, instead of l(mac). + type: str + required: no + scope_id: + description: + - Specifies the scope identifier as defined by the DHCP server. + - This is a required parameter, if l(state=present) and the reservation or lease + doesn't already exist. Not required if updating an existing lease or reservation. + type: str + mac: + description: + - Specifies the client identifier to be set on the IPv4 address. + - This is a required parameter, if l(ip) is not set. + - Windows clients use the MAC address as the client ID. + - Linux and other operating systems can use other types of identifiers. + - Can be used to identify an existing lease/reservation, instead of l(ip). + type: str + duration: + description: + - Specifies the duration of the DHCP lease in days. + - The duration value only applies to l(type=lease). + - Defaults to the duration specified by the DHCP server + configuration. + - Only applicable to l(type=lease). + type: int + dns_hostname: + description: + - Specifies the DNS hostname of the client for which the IP address + lease is to be added. + type: str + dns_regtype: + description: + - Indicates the type of DNS record to be registered by the DHCP. + server service for this lease. + - l(a) results in an A record being registered. + - l(aptr) results in both A and PTR records to be registered. + - l(noreg) results in no DNS records being registered. + type: str + default: aptr + choices: [ aptr, a, noreg ] + reservation_name: + description: + - Specifies the name of the reservation being created. + - Only applicable to l(type=reservation). + type: str + description: + description: + - Specifies the description for reservation being created. + - Only applicable to l(type=reservation). + type: str +''' + +EXAMPLES = r''' +- name: Ensure DHCP reservation exists + community.windows.win_dhcp_lease: + type: reservation + ip: 192.168.100.205 + scope_id: 192.168.100.0 + mac: 00:B1:8A:D1:5A:1F + dns_hostname: "{{ ansible_inventory }}" + description: Testing Server + +- name: Ensure DHCP lease or reservation does not exist + community.windows.win_dhcp_lease: + mac: 00:B1:8A:D1:5A:1F + state: absent + +- name: Ensure DHCP lease or reservation does not exist + community.windows.win_dhcp_lease: + ip: 192.168.100.205 + state: absent + +- name: Convert DHCP lease to reservation & update description + community.windows.win_dhcp_lease: + type: reservation + ip: 192.168.100.205 + description: Testing Server + +- name: Convert DHCP reservation to lease + community.windows.win_dhcp_lease: + type: lease + ip: 192.168.100.205 +''' + +RETURN = r''' +lease: + description: New/Updated DHCP object parameters + returned: When l(state=present) + type: dict + sample: + address_state: InactiveReservation + client_id: 0a-0b-0c-04-05-aa + description: Really Fancy + ip_address: 172.16.98.230 + name: null + scope_id: 172.16.98.0 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_disk_facts.ps1 b/ansible_collections/community/windows/plugins/modules/win_disk_facts.ps1 new file mode 100644 index 00000000..af61af52 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_disk_facts.ps1 @@ -0,0 +1,280 @@ +#!powershell + +# Copyright: (c) 2017, Marc Tschapek <marc.tschapek@itelligence.de> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.2 + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 2.0 + +$spec = @{ + options = @{ + filter = @{ + type = "list" + elements = "str" + choices = "physical_disk", "virtual_disk", "win32_disk_drive", "partitions", "volumes" + default = "physical_disk", "virtual_disk", "win32_disk_drive", "partitions", "volumes" + } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +# Functions +function Test-Admin { + $CurrentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent()) + $IsAdmin = $CurrentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) + return $IsAdmin +} + +# Check admin rights +if (-not (Test-Admin)) { + $module.FailJson("Module was not started with elevated rights") +} + + +# Create a new result object +$module.Result.changed = $false +$module.Result.ansible_facts = @{ ansible_disks = @() } + +# Search disks +try { + $disks = Get-Disk +} +catch { + $module.FailJson("Failed to search the disks on the target: $($_.Exception.Message)", $_) +} + +foreach ($disk in $disks) { + $disk_info = @{} + if ("physical_disk" -in $module.Params.filter) { + + $pdisk = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { + $_.DeviceId -eq $disk.Number + + } + if ($pdisk) { + $disk_info["physical_disk"] += @{ + size = $pdisk.Size + allocated_size = $pdisk.AllocatedSize + device_id = $pdisk.DeviceId + friendly_name = $pdisk.FriendlyName + operational_status = $pdisk.OperationalStatus + health_status = $pdisk.HealthStatus + bus_type = $pdisk.BusType + usage_type = $pdisk.Usage + supported_usages = $pdisk.SupportedUsages + spindle_speed = $pdisk.SpindleSpeed + firmware_version = $pdisk.FirmwareVersion + physical_location = $pdisk.PhysicalLocation + manufacturer = $pdisk.Manufacturer + model = $pdisk.Model + can_pool = $pdisk.CanPool + indication_enabled = $pdisk.IsIndicationEnabled + partial = $pdisk.IsPartial + serial_number = $pdisk.SerialNumber + object_id = $pdisk.ObjectId + unique_id = $pdisk.UniqueId + } + if ([single]"$([System.Environment]::OSVersion.Version.Major).$([System.Environment]::OSVersion.Version.Minor)" -ge 6.3) { + $disk_info.physical_disk.media_type = $pdisk.MediaType + } + if (-not $pdisk.CanPool) { + $disk_info.physical_disk.cannot_pool_reason = $pdisk.CannotPoolReason + } + } + if ("virtual_disk" -in $module.Params.filter) { + $vdisk = Get-VirtualDisk -PhysicalDisk $pdisk -ErrorAction SilentlyContinue + if ($vdisk) { + $disk_info["virtual_disk"] += @{ + size = $vdisk.Size + allocated_size = $vdisk.AllocatedSize + footprint_on_pool = $vdisk.FootprintOnPool + name = $vdisk.name + friendly_name = $vdisk.FriendlyName + operational_status = $vdisk.OperationalStatus + health_status = $vdisk.HealthStatus + provisioning_type = $vdisk.ProvisioningType + allocation_unit_size = $vdisk.AllocationUnitSize + media_type = $vdisk.MediaType + parity_layout = $vdisk.ParityLayout + access = $vdisk.Access + detached_reason = $vdisk.DetachedReason + write_cache_size = $vdisk.WriteCacheSize + fault_domain_awareness = $vdisk.FaultDomainAwareness + inter_leave = $vdisk.InterLeave + deduplication_enabled = $vdisk.IsDeduplicationEnabled + enclosure_aware = $vdisk.IsEnclosureAware + manual_attach = $vdisk.IsManualAttach + snapshot = $vdisk.IsSnapshot + tiered = $vdisk.IsTiered + physical_sector_size = $vdisk.PhysicalSectorSize + logical_sector_size = $vdisk.LogicalSectorSize + available_copies = $vdisk.NumberOfAvailableCopies + columns = $vdisk.NumberOfColumns + groups = $vdisk.NumberOfGroups + physical_disk_redundancy = $vdisk.PhysicalDiskRedundancy + read_cache_size = $vdisk.ReadCacheSize + request_no_spof = $vdisk.RequestNoSinglePointOfFailure + resiliency_setting_name = $vdisk.ResiliencySettingName + object_id = $vdisk.ObjectId + unique_id_format = $vdisk.UniqueIdFormat + unique_id = $vdisk.UniqueId + } + } + } + } + if ("win32_disk_drive" -in $module.Params.filter) { + $win32_disk_drive = Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction SilentlyContinue | Where-Object { + if ($_.SerialNumber) { + $_.SerialNumber -eq $disk.SerialNumber + } + elseif ($disk.UniqueIdFormat -eq 'Vendor Specific') { + $_.PNPDeviceID -eq $disk.UniqueId.split(':')[0] + } + } + if ($win32_disk_drive) { + $disk_info["win32_disk_drive"] += @{ + availability = $win32_disk_drive.Availability + bytes_per_sector = $win32_disk_drive.BytesPerSector + capabilities = $win32_disk_drive.Capabilities + capability_descriptions = $win32_disk_drive.CapabilityDescriptions + caption = $win32_disk_drive.Caption + compression_method = $win32_disk_drive.CompressionMethod + config_manager_error_code = $win32_disk_drive.ConfigManagerErrorCode + config_manager_user_config = $win32_disk_drive.ConfigManagerUserConfig + creation_class_name = $win32_disk_drive.CreationClassName + default_block_size = $win32_disk_drive.DefaultBlockSize + description = $win32_disk_drive.Description + device_id = $win32_disk_drive.DeviceID + error_cleared = $win32_disk_drive.ErrorCleared + error_description = $win32_disk_drive.ErrorDescription + error_methodology = $win32_disk_drive.ErrorMethodology + firmware_revision = $win32_disk_drive.FirmwareRevision + index = $win32_disk_drive.Index + install_date = $win32_disk_drive.InstallDate + interface_type = $win32_disk_drive.InterfaceType + last_error_code = $win32_disk_drive.LastErrorCode + manufacturer = $win32_disk_drive.Manufacturer + max_block_size = $win32_disk_drive.MaxBlockSize + max_media_size = $win32_disk_drive.MaxMediaSize + media_loaded = $win32_disk_drive.MediaLoaded + media_type = $win32_disk_drive.MediaType + min_block_size = $win32_disk_drive.MinBlockSize + model = $win32_disk_drive.Model + name = $win32_disk_drive.Name + needs_cleaning = $win32_disk_drive.NeedsCleaning + number_of_media_supported = $win32_disk_drive.NumberOfMediaSupported + partitions = $win32_disk_drive.Partitions + pnp_device_id = $win32_disk_drive.PNPDeviceID + power_management_capabilities = $win32_disk_drive.PowerManagementCapabilities + power_management_supported = $win32_disk_drive.PowerManagementSupported + scsi_bus = $win32_disk_drive.SCSIBus + scsi_logical_unit = $win32_disk_drive.SCSILogicalUnit + scsi_port = $win32_disk_drive.SCSIPort + scsi_target_id = $win32_disk_drive.SCSITargetId + sectors_per_track = $win32_disk_drive.SectorsPerTrack + serial_number = $win32_disk_drive.SerialNumber + signature = $win32_disk_drive.Signature + size = $win32_disk_drive.Size + status = $win32_disk_drive.status + status_info = $win32_disk_drive.StatusInfo + system_creation_class_name = $win32_disk_drive.SystemCreationClassName + system_name = $win32_disk_drive.SystemName + total_cylinders = $win32_disk_drive.TotalCylinders + total_heads = $win32_disk_drive.TotalHeads + total_sectors = $win32_disk_drive.TotalSectors + total_tracks = $win32_disk_drive.TotalTracks + tracks_per_cylinder = $win32_disk_drive.TracksPerCylinder + } + } + } + $disk_info.number = $disk.Number + $disk_info.size = $disk.Size + $disk_info.bus_type = $disk.BusType + $disk_info.friendly_name = $disk.FriendlyName + $disk_info.partition_style = $disk.PartitionStyle + $disk_info.partition_count = $disk.NumberOfPartitions + $disk_info.operational_status = $disk.OperationalStatus + $disk_info.sector_size = $disk.PhysicalSectorSize + $disk_info.read_only = $disk.IsReadOnly + $disk_info.bootable = $disk.IsBoot + $disk_info.system_disk = $disk.IsSystem + $disk_info.clustered = $disk.IsClustered + $disk_info.manufacturer = $disk.Manufacturer + $disk_info.model = $disk.Model + $disk_info.firmware_version = $disk.FirmwareVersion + $disk_info.location = $disk.Location + $disk_info.serial_number = $disk.SerialNumber + $disk_info.unique_id = $disk.UniqueId + $disk_info.guid = $disk.Guid + $disk_info.path = $disk.Path + if ("partitions" -in $module.Params.filter -or "volumes" -in $module.Params.filter) { + $parts = Get-Partition -DiskNumber $($disk.Number) -ErrorAction SilentlyContinue + if ($parts) { + $disk_info["partitions"] += @() + foreach ($part in $parts) { + $partition_info = @{ + number = $part.PartitionNumber + size = $part.Size + type = $part.Type + drive_letter = $part.DriveLetter + transition_state = $part.TransitionState + offset = $part.Offset + hidden = $part.IsHidden + shadow_copy = $part.IsShadowCopy + guid = $part.Guid + access_paths = $part.AccessPaths + } + if ($disks.PartitionStyle -eq "GPT") { + $partition_info.gpt_type = $part.GptType + $partition_info.no_default_driveletter = $part.NoDefaultDriveLetter + } + elseif ($disks.PartitionStyle -eq "MBR") { + $partition_info.mbr_type = $part.MbrType + $partition_info.active = $part.IsActive + } + if ("volumes" -in $module.Params.filter) { + $vols = Get-Volume -Partition $part -ErrorAction SilentlyContinue + if ($vols) { + $partition_info["volumes"] += @() + foreach ($vol in $vols) { + $volume_info = @{ + size = $vol.Size + size_remaining = $vol.SizeRemaining + type = $vol.FileSystem + label = $vol.FileSystemLabel + health_status = $vol.HealthStatus + drive_type = $vol.DriveType + object_id = $vol.ObjectId + path = $vol.Path + } + if ([System.Environment]::OSVersion.Version.Major -ge 10) { + $volume_info.allocation_unit_size = $vol.AllocationUnitSize + } + else { + $volPath = ($vol.Path.TrimStart("\\?\")).TrimEnd("\") + $BlockSize = ( + Get-CimInstance ` + -Query "SELECT BlockSize FROM Win32_Volume WHERE DeviceID like '%$volPath%'" ` + -ErrorAction SilentlyContinue | Select-Object BlockSize).BlockSize + $volume_info.allocation_unit_size = $BlockSize + } + $partition_info.volumes += $volume_info + } + } + } + $disk_info.partitions += $partition_info + } + } + } + $module.Result.ansible_facts.ansible_disks += $disk_info +} + +# Sort by disk number property +$module.Result.ansible_facts.ansible_disks = @() + ($module.Result.ansible_facts.ansible_disks | Sort-Object -Property { $_.Number }) + +# Return result +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_disk_facts.py b/ansible_collections/community/windows/plugins/modules/win_disk_facts.py new file mode 100644 index 00000000..a303e571 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_disk_facts.py @@ -0,0 +1,902 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Marc Tschapek <marc.tschapek@itelligence.de> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_disk_facts +short_description: Show the attached disks and disk information of the target host +description: + - With the module you can retrieve and output detailed information about the attached disks of the target and + its volumes and partitions if existent. +requirements: + - Windows 8.1 / Windows 2012 (NT 6.2) +notes: + - In order to understand all the returned properties and values please visit the following site and open the respective MSFT class + U(https://msdn.microsoft.com/en-us/library/windows/desktop/hh830612.aspx) +author: + - Marc Tschapek (@marqelme) +options: + filter: + description: + - Allows to filter returned facts by type of disk information. + - If volumes are selected partitions will be returned as well. + type: list + elements: str + choices: [ physical_disk, virtual_disk, win32_disk_drive, partitions, volumes ] + default: [ physical_disk, virtual_disk, win32_disk_drive, partitions, volumes ] + version_added: 1.9.0 +''' + +EXAMPLES = r''' +- name: Get disk facts + community.windows.win_disk_facts: + +- name: Output first disk size + debug: + var: ansible_facts.disks[0].size + +- name: Convert first system disk into various formats + debug: + msg: '{{ disksize_gib }} vs {{ disksize_gib_human }}' + vars: + # Get first system disk + disk: '{{ ansible_facts.disks|selectattr("system_disk")|first }}' + + # Show disk size in Gibibytes + disksize_gib_human: '{{ disk.size|filesizeformat(true) }}' # returns "223.6 GiB" (human readable) + disksize_gib: '{{ (disk.size/1024|pow(3))|round|int }} GiB' # returns "224 GiB" (value in GiB) + + # Show disk size in Gigabytes + disksize_gb_human: '{{ disk.size|filesizeformat }}' # returns "240.1 GB" (human readable) + disksize_gb: '{{ (disk.size/1000|pow(3))|round|int }} GB' # returns "240 GB" (value in GB) + +- name: Output second disk serial number + debug: + var: ansible_facts.disks[1].serial_number + +- name: get disk physical_disk and partition facts on the target + win_disk_facts: + filter: + - physical_disk + - partitions +''' + +RETURN = r''' +ansible_facts: + description: Dictionary containing all the detailed information about the disks of the target. + returned: always + type: complex + contains: + ansible_disks: + description: Detailed information about one particular disk. + returned: if disks were found + type: list + contains: + number: + description: Disk number of the particular disk. + returned: always + type: int + sample: 0 + size: + description: Size in bytes of the particular disk. + returned: always + type: int + sample: 227727638528 + bus_type: + description: Bus type of the particular disk. + returned: always + type: str + sample: "SCSI" + friendly_name: + description: Friendly name of the particular disk. + returned: always + type: str + sample: "Red Hat VirtIO SCSI Disk Device" + partition_style: + description: Partition style of the particular disk. + returned: always + type: str + sample: "MBR" + partition_count: + description: Number of partitions on the particular disk. + returned: always + type: int + sample: 4 + operational_status: + description: Operational status of the particular disk. + returned: always + type: str + sample: "Online" + sector_size: + description: Sector size in bytes of the particular disk. + returned: always + type: int + sample: 4096 + read_only: + description: Read only status of the particular disk. + returned: always + type: bool + sample: true + bootable: + description: Information whether the particular disk is a bootable disk. + returned: always + type: bool + sample: false + system_disk: + description: Information whether the particular disk is a system disk. + returned: always + type: bool + sample: true + clustered: + description: Information whether the particular disk is clustered (part of a failover cluster). + returned: always + type: bool + sample: false + manufacturer: + description: Manufacturer of the particular disk. + returned: always + type: str + sample: "Red Hat" + model: + description: Model specification of the particular disk. + returned: always + type: str + sample: "VirtIO" + firmware_version: + description: Firmware version of the particular disk. + returned: always + type: str + sample: "0001" + location: + description: Location of the particular disk on the target. + returned: always + type: str + sample: "PCIROOT(0)#PCI(0400)#SCSI(P00T00L00)" + serial_number: + description: Serial number of the particular disk on the target. + returned: always + type: str + sample: "b62beac80c3645e5877f" + unique_id: + description: Unique ID of the particular disk on the target. + returned: always + type: str + sample: "3141463431303031" + guid: + description: GUID of the particular disk on the target. + returned: if existent + type: str + sample: "{efa5f928-57b9-47fc-ae3e-902e85fbe77f}" + path: + description: Path of the particular disk on the target. + returned: always + type: str + sample: "\\\\?\\scsi#disk&ven_red_hat&prod_virtio#4&23208fd0&1&000000#{<id>}" + partitions: + description: Detailed information about one particular partition on the specified disk. + returned: if existent + type: list + contains: + number: + description: Number of the particular partition. + returned: always + type: int + sample: 1 + size: + description: + - Size in bytes of the particular partition. + returned: always + type: int + sample: 838860800 + type: + description: Type of the particular partition. + returned: always + type: str + sample: "IFS" + gpt_type: + description: gpt type of the particular partition. + returned: if partition_style property of the particular disk has value "GPT" + type: str + sample: "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" + no_default_driveletter: + description: Information whether the particular partition has a default drive letter or not. + returned: if partition_style property of the particular disk has value "GPT" + type: bool + sample: true + mbr_type: + description: mbr type of the particular partition. + returned: if partition_style property of the particular disk has value "MBR" + type: int + sample: 7 + active: + description: Information whether the particular partition is an active partition or not. + returned: if partition_style property of the particular disk has value "MBR" + type: bool + sample: true + drive_letter: + description: Drive letter of the particular partition. + returned: if existent + type: str + sample: "C" + transition_state: + description: Transition state of the particular partition. + returned: always + type: int + sample: 1 + offset: + description: Offset of the particular partition. + returned: always + type: int + sample: 368050176 + hidden: + description: Information whether the particular partition is hidden or not. + returned: always + type: bool + sample: true + shadow_copy: + description: Information whether the particular partition is a shadow copy of another partition. + returned: always + type: bool + sample: false + guid: + description: GUID of the particular partition. + returned: if existent + type: str + sample: "{302e475c-6e64-4674-a8e2-2f1c7018bf97}" + access_paths: + description: Access paths of the particular partition. + returned: if existent + type: str + sample: "\\\\?\\Volume{85bdc4a8-f8eb-11e6-80fa-806e6f6e6963}\\" + volumes: + description: Detailed information about one particular volume on the specified partition. + returned: if existent + type: list + contains: + size: + description: + - Size in bytes of the particular volume. + returned: always + type: int + sample: 838856704 + size_remaining: + description: + - Remaining size in bytes of the particular volume. + returned: always + type: int + sample: 395620352 + type: + description: File system type of the particular volume. + returned: always + type: str + sample: "NTFS" + label: + description: File system label of the particular volume. + returned: always + type: str + sample: "System Reserved" + health_status: + description: Health status of the particular volume. + returned: always + type: str + sample: "Healthy" + drive_type: + description: Drive type of the particular volume. + returned: always + type: str + sample: "Fixed" + allocation_unit_size: + description: Allocation unit size in bytes of the particular volume. + returned: always + type: int + sample: 4096 + object_id: + description: Object ID of the particular volume. + returned: always + type: str + sample: "\\\\?\\Volume{85bdc4a9-f8eb-11e6-80fa-806e6f6e6963}\\" + path: + description: Path of the particular volume. + returned: always + type: str + sample: "\\\\?\\Volume{85bdc4a9-f8eb-11e6-80fa-806e6f6e6963}\\" + physical_disk: + description: Detailed information about physical disk properties of the particular disk. + returned: if existent + type: complex + contains: + media_type: + description: Media type of the particular physical disk. + returned: always + type: str + sample: "UnSpecified" + size: + description: + - Size in bytes of the particular physical disk. + returned: always + type: int + sample: 240057409536 + allocated_size: + description: + - Allocated size in bytes of the particular physical disk. + returned: always + type: int + sample: 240057409536 + device_id: + description: Device ID of the particular physical disk. + returned: always + type: str + sample: "0" + friendly_name: + description: Friendly name of the particular physical disk. + returned: always + type: str + sample: "PhysicalDisk0" + operational_status: + description: Operational status of the particular physical disk. + returned: always + type: str + sample: "OK" + health_status: + description: Health status of the particular physical disk. + returned: always + type: str + sample: "Healthy" + bus_type: + description: Bus type of the particular physical disk. + returned: always + type: str + sample: "SCSI" + usage_type: + description: Usage type of the particular physical disk. + returned: always + type: str + sample: "Auto-Select" + supported_usages: + description: Supported usage types of the particular physical disk. + returned: always + type: complex + contains: + Count: + description: Count of supported usage types. + returned: always + type: int + sample: 5 + value: + description: List of supported usage types. + returned: always + type: str + sample: "Auto-Select, Hot Spare" + spindle_speed: + description: Spindle speed in rpm of the particular physical disk. + returned: always + type: int + sample: 4294967295 + physical_location: + description: Physical location of the particular physical disk. + returned: always + type: str + sample: "Integrated : Adapter 3 : Port 0 : Target 0 : LUN 0" + manufacturer: + description: Manufacturer of the particular physical disk. + returned: always + type: str + sample: "SUSE" + model: + description: Model of the particular physical disk. + returned: always + type: str + sample: "Xen Block" + can_pool: + description: Information whether the particular physical disk can be added to a storage pool. + returned: always + type: bool + sample: false + cannot_pool_reason: + description: Information why the particular physical disk can not be added to a storage pool. + returned: if can_pool property has value false + type: str + sample: "Insufficient Capacity" + indication_enabled: + description: Information whether indication is enabled for the particular physical disk. + returned: always + type: bool + sample: true + partial: + description: Information whether the particular physical disk is partial. + returned: always + type: bool + sample: false + serial_number: + description: Serial number of the particular physical disk. + returned: always + type: str + sample: "b62beac80c3645e5877f" + object_id: + description: Object ID of the particular physical disk. + returned: always + type: str + sample: '{1}\\\\HOST\\root/Microsoft/Windows/Storage/Providers_v2\\SPACES_PhysicalDisk.ObjectId=\"{<object_id>}:PD:{<pd>}\"' + unique_id: + description: Unique ID of the particular physical disk. + returned: always + type: str + sample: "3141463431303031" + virtual_disk: + description: Detailed information about virtual disk properties of the particular disk. + returned: if existent + type: complex + contains: + size: + description: + - Size in bytes of the particular virtual disk. + returned: always + type: int + sample: 240057409536 + allocated_size: + description: + - Allocated size in bytes of the particular virtual disk. + returned: always + type: int + sample: 240057409536 + footprint_on_pool: + description: + - Footprint on pool in bytes of the particular virtual disk. + returned: always + type: int + sample: 240057409536 + name: + description: Name of the particular virtual disk. + returned: always + type: str + sample: "vDisk1" + friendly_name: + description: Friendly name of the particular virtual disk. + returned: always + type: str + sample: "Prod2 Virtual Disk" + operational_status: + description: Operational status of the particular virtual disk. + returned: always + type: str + sample: "OK" + health_status: + description: Health status of the particular virtual disk. + returned: always + type: str + sample: "Healthy" + provisioning_type: + description: Provisioning type of the particular virtual disk. + returned: always + type: str + sample: "Thin" + allocation_unit_size: + description: Allocation unit size in bytes of the particular virtual disk. + returned: always + type: int + sample: 4096 + media_type: + description: Media type of the particular virtual disk. + returned: always + type: str + sample: "Unspecified" + parity_layout: + description: Parity layout of the particular virtual disk. + returned: if existent + type: int + sample: 1 + access: + description: Access of the particular virtual disk. + returned: always + type: str + sample: "Read/Write" + detached_reason: + description: Detached reason of the particular virtual disk. + returned: always + type: str + sample: "None" + write_cache_size: + description: Write cache size in byte of the particular virtual disk. + returned: always + type: int + sample: 100 + fault_domain_awareness: + description: Fault domain awareness of the particular virtual disk. + returned: always + type: str + sample: "PhysicalDisk" + inter_leave: + description: + - Inter leave in bytes of the particular virtual disk. + returned: always + type: int + sample: 102400 + deduplication_enabled: + description: Information whether deduplication is enabled for the particular virtual disk. + returned: always + type: bool + sample: true + enclosure_aware: + description: Information whether the particular virtual disk is enclosure aware. + returned: always + type: bool + sample: false + manual_attach: + description: Information whether the particular virtual disk is manual attached. + returned: always + type: bool + sample: true + snapshot: + description: Information whether the particular virtual disk is a snapshot. + returned: always + type: bool + sample: false + tiered: + description: Information whether the particular virtual disk is tiered. + returned: always + type: bool + sample: true + physical_sector_size: + description: Physical sector size in bytes of the particular virtual disk. + returned: always + type: int + sample: 4096 + logical_sector_size: + description: Logical sector size in byte of the particular virtual disk. + returned: always + type: int + sample: 512 + available_copies: + description: Number of the available copies of the particular virtual disk. + returned: if existent + type: int + sample: 1 + columns: + description: Number of the columns of the particular virtual disk. + returned: always + type: int + sample: 2 + groups: + description: Number of the groups of the particular virtual disk. + returned: always + type: int + sample: 1 + physical_disk_redundancy: + description: Type of the physical disk redundancy of the particular virtual disk. + returned: always + type: int + sample: 1 + read_cache_size: + description: Read cache size in byte of the particular virtual disk. + returned: always + type: int + sample: 0 + request_no_spof: + description: Information whether the particular virtual disk requests no single point of failure. + returned: always + type: bool + sample: true + resiliency_setting_name: + description: Type of the physical disk redundancy of the particular virtual disk. + returned: always + type: int + sample: 1 + object_id: + description: Object ID of the particular virtual disk. + returned: always + type: str + sample: '{1}\\\\HOST\\root/Microsoft/Windows/Storage/Providers_v2\\SPACES_VirtualDisk.ObjectId=\"{<object_id>}:VD:{<vd>}\"' + unique_id: + description: Unique ID of the particular virtual disk. + returned: always + type: str + sample: "260542E4C6B01D47A8FA7630FD90FFDE" + unique_id_format: + description: Unique ID format of the particular virtual disk. + returned: always + type: str + sample: "Vendor Specific" + win32_disk_drive: + description: Representation of the Win32_DiskDrive class. + returned: if existent + type: complex + contains: + availability: + description: Availability and status of the device. + returned: always + type: int + bytes_per_sector: + description: Number of bytes in each sector for the physical disk drive. + returned: always + type: int + sample: 512 + capabilities: + description: + - Array of capabilities of the media access device. + - For example, the device may support random access (3), removable media (7), and automatic cleaning (9). + returned: always + type: list + sample: + - 3 + - 4 + capability_descriptions: + description: + - List of more detailed explanations for any of the access device features indicated in the Capabilities array. + - Note, each entry of this array is related to the entry in the Capabilities array that is located at the same index. + returned: always + type: list + sample: + - Random Access + - Supports Writing + caption: + description: Short description of the object. + returned: always + type: str + sample: VMware Virtual disk SCSI Disk Device + compression_method: + description: Algorithm or tool used by the device to support compression. + returned: always + type: str + sample: Compressed + config_manager_error_code: + description: Windows Configuration Manager error code. + returned: always + type: int + sample: 0 + config_manager_user_config: + description: If True, the device is using a user-defined configuration. + returned: always + type: bool + sample: true + creation_class_name: + description: + - Name of the first concrete class to appear in the inheritance chain used in the creation of an instance. + - When used with the other key properties of the class, the property allows all instances of this class + - and its subclasses to be uniquely identified. + returned: always + type: str + sample: Win32_DiskDrive + default_block_size: + description: Default block size, in bytes, for this device. + returned: always + type: int + sample: 512 + description: + description: Description of the object. + returned: always + type: str + sample: Disk drive + device_id: + description: Unique identifier of the disk drive with other devices on the system. + returned: always + type: str + sample: "\\\\.\\PHYSICALDRIVE0" + error_cleared: + description: If True, the error reported in LastErrorCode is now cleared. + returned: always + type: bool + sample: true + error_description: + description: + - More information about the error recorded in LastErrorCode, + - and information on any corrective actions that may be taken. + returned: always + type: str + error_methodology: + description: Type of error detection and correction supported by this device. + returned: always + type: str + firmware_revision: + description: Revision for the disk drive firmware that is assigned by the manufacturer. + returned: always + type: str + sample: 1.0 + index: + description: + - Physical drive number of the given drive. + - This property is filled by the STORAGE_DEVICE_NUMBER structure returned from the IOCTL_STORAGE_GET_DEVICE_NUMBER control code + - A value of 0xffffffff indicates that the given drive does not map to a physical drive. + returned: always + type: int + sample: 0 + install_date: + description: Date and time the object was installed. This property does not need a value to indicate that the object is installed. + returned: always + type: str + interface_type: + description: Interface type of physical disk drive. + returned: always + type: str + sample: SCSI + last_error_code: + description: Last error code reported by the logical device. + returned: always + type: int + manufacturer: + description: Name of the disk drive manufacturer. + returned: always + type: str + sample: Seagate + max_block_size: + description: Maximum block size, in bytes, for media accessed by this device. + returned: always + type: int + max_media_size: + description: Maximum media size, in kilobytes, of media supported by this device. + returned: always + type: int + media_loaded: + description: + - If True, the media for a disk drive is loaded, which means that the device has a readable file system and is accessible. + - For fixed disk drives, this property will always be TRUE. + returned: always + type: bool + sample: true + media_type: + description: Type of media used or accessed by this device. + returned: always + type: str + sample: Fixed hard disk media + min_block_size: + description: Minimum block size, in bytes, for media accessed by this device. + returned: always + type: int + model: + description: Manufacturer's model number of the disk drive. + returned: always + type: str + sample: ST32171W + name: + description: Label by which the object is known. When subclassed, the property can be overridden to be a key property. + returned: always + type: str + sample: \\\\.\\PHYSICALDRIVE0 + needs_cleaning: + description: + - If True, the media access device needs cleaning. + - Whether manual or automatic cleaning is possible is indicated in the Capabilities property. + returned: always + type: bool + number_of_media_supported: + description: + - Maximum number of media which can be supported or inserted + - (when the media access device supports multiple individual media). + returned: always + type: int + partitions: + description: Number of partitions on this physical disk drive that are recognized by the operating system. + returned: always + type: int + sample: 3 + pnp_device_id: + description: Windows Plug and Play device identifier of the logical device. + returned: always + type: str + sample: "SCSI\\DISK&VEN_VMWARE&PROD_VIRTUAL_DISK\\5&1982005&0&000000" + power_management_capabilities: + description: Array of the specific power-related capabilities of a logical device. + returned: always + type: list + power_management_supported: + description: + - If True, the device can be power-managed (can be put into suspend mode, and so on). + - The property does not indicate that power management features are currently enabled, + - only that the logical device is capable of power management. + returned: always + type: bool + scsi_bus: + description: SCSI bus number of the disk drive. + returned: always + type: int + sample: 0 + scsi_logical_unit: + description: SCSI logical unit number (LUN) of the disk drive. + returned: always + type: int + sample: 0 + scsi_port: + description: SCSI port number of the disk drive. + returned: always + type: int + sample: 0 + scsi_target_id: + description: SCSI identifier number of the disk drive. + returned: always + type: int + sample: 0 + sectors_per_track: + description: Number of sectors in each track for this physical disk drive. + returned: always + type: int + sample: 63 + serial_number: + description: Number allocated by the manufacturer to identify the physical media. + returned: always + type: str + sample: 6000c298f34101b38cb2b2508926b9de + signature: + description: Disk identification. This property can be used to identify a shared resource. + returned: always + type: int + size: + description: + - Size of the disk drive. It is calculated by multiplying the total number of cylinders, tracks in each cylinder, + - sectors in each track, and bytes in each sector. + returned: always + type: int + sample: 53686402560 + status: + description: + - Current status of the object. Various operational and nonoperational statuses can be defined. + - 'Operational statuses include: "OK", "Degraded", and "Pred Fail"' + - (an element, such as a SMART-enabled hard disk drive, may be functioning properly but predicting a failure in the near future). + - 'Nonoperational statuses include: "Error", "Starting", "Stopping", and "Service".' + - '"Service", could apply during mirror-resilvering of a disk, reload of a user permissions list, or other administrative work.' + - Not all such work is online, yet the managed element is neither "OK" nor in one of the other states. + returned: always + type: str + sample: OK + status_info: + description: + - State of the logical device. If this property does not apply to the logical device, the value 5 (Not Applicable) should be used. + returned: always + type: int + system_creation_class_name: + description: Value of the scoping computer's CreationClassName property. + returned: always + type: str + sample: Win32_ComputerSystem + system_name: + description: Name of the scoping system. + returned: always + type: str + sample: WILMAR-TEST-123 + total_cylinders: + description: + - Total number of cylinders on the physical disk drive. + - 'Note: the value for this property is obtained through extended functions of BIOS interrupt 13h.' + - The value may be inaccurate if the drive uses a translation scheme to support high-capacity disk sizes. + - Consult the manufacturer for accurate drive specifications. + returned: always + type: int + sample: 6527 + total_heads: + description: + - Total number of heads on the disk drive. + - 'Note: the value for this property is obtained through extended functions of BIOS interrupt 13h.' + - The value may be inaccurate if the drive uses a translation scheme to support high-capacity disk sizes. + - Consult the manufacturer for accurate drive specifications. + returned: always + type: int + sample: 255 + total_sectors: + description: + - Total number of sectors on the physical disk drive. + - 'Note: the value for this property is obtained through extended functions of BIOS interrupt 13h.' + - The value may be inaccurate if the drive uses a translation scheme to support high-capacity disk sizes. + - Consult the manufacturer for accurate drive specifications. + returned: always + type: int + sample: 104856255 + total_tracks: + description: + - Total number of tracks on the physical disk drive. + - 'Note: the value for this property is obtained through extended functions of BIOS interrupt 13h.' + - The value may be inaccurate if the drive uses a translation scheme to support high-capacity disk sizes. + - Consult the manufacturer for accurate drive specifications. + returned: always + type: int + sample: 1664385 + tracks_per_cylinder: + description: + - Number of tracks in each cylinder on the physical disk drive. + - 'Note: the value for this property is obtained through extended functions of BIOS interrupt 13h.' + - The value may be inaccurate if the drive uses a translation scheme to support high-capacity disk sizes. + - Consult the manufacturer for accurate drive specifications. + returned: always + type: int + sample: 255 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_disk_image.ps1 b/ansible_collections/community/windows/plugins/modules/win_disk_image.ps1 new file mode 100644 index 00000000..99ef3b5f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_disk_image.ps1 @@ -0,0 +1,79 @@ +#!powershell + +# Copyright: (c) 2017, Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 2 + +If (-not (Get-Command Get-DiskImage -ErrorAction SilentlyContinue)) { + Fail-Json -message "win_disk_image requires Windows 8+ or Windows Server 2012+" +} + +$parsed_args = Parse-Args $args -supports_check_mode $true + +$result = @{ changed = $false } + +$image_path = Get-AnsibleParam $parsed_args "image_path" -failifempty $result +$state = Get-AnsibleParam $parsed_args "state" -default "present" -validateset "present", "absent" +$check_mode = Get-AnsibleParam $parsed_args "_ansible_check_mode" -default $false + +$di = Get-DiskImage $image_path + +If ($state -eq "present") { + If (-not $di.Attached) { + $result.changed = $true + + If (-not $check_mode) { + $di = Mount-DiskImage $image_path -PassThru + + # the actual mount is async, so the CIMInstance result may not immediately contain the data we need + $retry_count = 0 + While (-not $di.Attached -and $retry_count -lt 5) { + Start-Sleep -Seconds 1 > $null + $di = $di | Get-DiskImage + $retry_count++ + } + + If (-not $di.Attached) { + Fail-Json $result -message "Timed out waiting for disk to attach" + } + } + } + + # FUTURE: detect/handle "ejected" ISOs + # FUTURE: support explicit drive letter and NTFS in-volume mountpoints. + # VHDs don't always auto-assign, and other system settings can prevent automatic assignment + + If ($di.Attached) { + # only try to get the mount_path if the disk is attached ( + If ($di.StorageType -eq 1) { + # ISO, we can get the mountpoint directly from Get-Volume + $drive_letters = ($di | Get-Volume).DriveLetter + } + ElseIf ($di.StorageType -in @(2, 3)) { + # VHD/VHDX, need Get-Disk + Get-Partition to discover mountpoint + $drive_letters = ($di | Get-Disk | Get-Partition).DriveLetter + } + # remove any null entries (no drive letter) + $drive_letters = $drive_letters | Where-Object { $_ } + + If (-not $drive_letters) { + Fail-Json -message "Unable to retrieve drive letter from mounted image" + } + + $result.mount_paths = @($drive_letters | ForEach-Object { "$($_):\" }) + } +} +ElseIf ($state -eq "absent") { + If ($di.Attached) { + $result.changed = $true + If (-not $check_mode) { + Dismount-DiskImage $image_path > $null + } + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_disk_image.py b/ansible_collections/community/windows/plugins/modules/win_disk_image.py new file mode 100644 index 00000000..e2037da3 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_disk_image.py @@ -0,0 +1,57 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +module: win_disk_image +short_description: Manage ISO/VHD/VHDX mounts on Windows hosts +description: + - Manages mount behavior for a specified ISO, VHD, or VHDX image on a Windows host. When C(state) is C(present), + the image will be mounted under a system-assigned drive letter, which will be returned in the C(mount_path) value + of the module result. + - Requires Windows 8+ or Windows Server 2012+. +options: + image_path: + description: + - Path to an ISO, VHD, or VHDX image on the target Windows host (the file cannot reside on a network share) + type: str + required: yes + state: + description: + - Whether the image should be present as a drive-letter mount or not. + type: str + choices: [ absent, present ] + default: present +author: + - Matt Davis (@nitzmahone) +''' + +EXAMPLES = r''' +# Run installer from mounted ISO, then unmount +- name: Ensure an ISO is mounted + community.windows.win_disk_image: + image_path: C:\install.iso + state: present + register: disk_image_out + +- name: Run installer from mounted ISO + ansible.windows.win_package: + path: '{{ disk_image_out.mount_paths[0] }}setup\setup.exe' + product_id: 35a4e767-0161-46b0-979f-e61f282fee21 + state: present + +- name: Unmount ISO + community.windows.win_disk_image: + image_path: C:\install.iso + state: absent +''' + +RETURN = r''' +mount_paths: + description: A list of filesystem paths mounted from the target image. + returned: when C(state) is C(present) + type: list + sample: [ 'E:\', 'F:\' ] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_dns_record.ps1 b/ansible_collections/community/windows/plugins/modules/win_dns_record.ps1 new file mode 100644 index 00000000..84293253 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dns_record.ps1 @@ -0,0 +1,202 @@ +#!powershell +# Copyright: (c) 2021 Sebastian Gruber ,dacoso GmbH All Rights Reserved. +# Copyright: (c) 2019, Hitachi ID Systems, Inc. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str"; required = $true } + port = @{ type = "int" } + priority = @{ type = "int" } + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + ttl = @{ type = "int"; default = "3600" } + type = @{ type = "str"; choices = "A", "AAAA", "CNAME", "DHCID", "NS", "PTR", "SRV", "TXT"; required = $true } + value = @{ type = "list"; elements = "str"; default = @() ; aliases = @( 'values' ) } + weight = @{ type = "int" } + zone = @{ type = "str"; required = $true } + computer_name = @{ type = "str" } + } + required_if = @(, @("type", "SRV", @("port", "priority", "weight"))) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$name = $module.Params.name +$port = $module.Params.port +$priority = $module.Params.priority +$state = $module.Params.state +$ttl = $module.Params.ttl +$type = $module.Params.type +$values = $module.Params.value +$weight = $module.Params.weight +$zone = $module.Params.zone +$dns_computer_name = $module.Params.computer_name +$extra_args = @{} + +if ($null -ne $dns_computer_name) { + $extra_args.ComputerName = $dns_computer_name +} + +if ($state -eq 'present') { + if ($values.Count -eq 0) { + $module.FailJson("Parameter 'values' must be non-empty when state='present'") + } +} +else { + if ($values.Count -ne 0) { + $module.FailJson("Parameter 'values' must be undefined or empty when state='absent'") + } +} + +# TODO: add warning for forest minTTL override -- see https://docs.microsoft.com/en-us/windows/desktop/ad/configuration-of-ttl-limits +if ($ttl -lt 1 -or $ttl -gt 31557600) { + $module.FailJson("Parameter 'ttl' must be between 1 and 31557600") +} + +$ttl = New-TimeSpan -Seconds $ttl + +if (($type -eq 'CNAME' -or $type -eq 'NS' -or $type -eq 'PTR' -or $type -eq 'SRV') -and $null -ne $values -and $values.Count -gt 0 -and $zone[-1] -ne '.') { + # CNAMEs and PTRs should be '.'-terminated, or record matching will fail + $values = $values | ForEach-Object { + if ($_ -Like "*.") { $_ } else { "$_." } + } +} + +$record_argument_name = @{ + A = "IPv4Address" + AAAA = "IPv6Address" + CNAME = "HostNameAlias" + DHCID = "DhcpIdentifier" + # MX = "MailExchange" + NS = "NameServer" + PTR = "PtrDomainName" + SRV = "DomainName" + TXT = "DescriptiveText" +}[$type] + +function Get-DnsServerResourceRecordDataPropertyName { + Switch -Exact ($type) { + 'DHCID' { + 'DhcId' + } + default { + $record_argument_name + } + } +} + +$changes = @{ + before = "" + after = "" +} + +$records = Get-DnsServerResourceRecord -ZoneName $zone -Name $name -RRType $type -Node -ErrorAction:Ignore @extra_args | Sort-Object + +if ($null -ne $records) { + # We use [Hashtable]$required_values below as a set rather than a map. + # It provides quick lookup to test existing DNS record against. By removing + # items as each is processed, whatever remains at the end is missing + # content (that needs to be added). + $required_values = @{} + foreach ($value in $values) { + $required_values[$value.ToString()] = $null + } + + foreach ($record in $records) { + $record_value = $record.RecordData.$(Get-DnsServerResourceRecordDataPropertyName).ToString() + + if (-Not $required_values.ContainsKey($record_value)) { + $record | Remove-DnsServerResourceRecord -ZoneName $zone -Force -WhatIf:$module.CheckMode @extra_args + $changes.before += "[$zone] $($record.HostName) $($record.TimeToLive.TotalSeconds) IN $type $record_value`n" + $module.Result.changed = $true + } + else { + if ($type -eq 'SRV') { + $record_port_old = $record.RecordData.Port.ToString() + $record_priority_old = $record.RecordData.Priority.ToString() + $record_weight_old = $record.RecordData.Weight.ToString() + + if ($record.TimeToLive -ne $ttl -or $port -ne $record_port_old -or $priority -ne $record_priority_old -or $weight -ne $record_weight_old) { + $new_record = $record.Clone() + $new_record.TimeToLive = $ttl + $new_record.RecordData.Port = $port + $new_record.RecordData.Priority = $priority + $new_record.RecordData.Weight = $weight + Set-DnsServerResourceRecord -ZoneName $zone -OldInputObject $record -NewInputObject $new_record -WhatIf:$module.CheckMode @extra_args + + $changes.before += -join @( + "[$zone] $($record.HostName) $($record.TimeToLive.TotalSeconds) IN " + "$type $record_value $record_port_old $record_weight_old $record_priority_old`n" + ) + $changes.after += "[$zone] $($record.HostName) $($ttl.TotalSeconds) IN $type $record_value $port $weight $priority`n" + $module.Result.changed = $true + } + } + else { + # This record matches one of the values; but does it match the TTL? + if ($record.TimeToLive -ne $ttl) { + $new_record = $record.Clone() + $new_record.TimeToLive = $ttl + Set-DnsServerResourceRecord -ZoneName $zone -OldInputObject $record -NewInputObject $new_record -WhatIf:$module.CheckMode @extra_args + $changes.before += "[$zone] $($record.HostName) $($record.TimeToLive.TotalSeconds) IN $type $record_value`n" + $changes.after += "[$zone] $($record.HostName) $($ttl.TotalSeconds) IN $type $record_value`n" + $module.Result.changed = $true + } + } + # Cross this one off the list, so we don't try adding it late + $required_values.Remove($record_value) + # Whatever is left in $required_values needs to be added + $values = $required_values.Keys + } + } +} + +if ($null -ne $values -and $values.Count -gt 0) { + foreach ($value in $values) { + $splat_args = @{ $type = $true; $record_argument_name = $value } + $module.Result.debug_splat_args = $splat_args + $srv_args = @{ + DomainName = $value + Weight = $weight + Priority = $priority + Port = $port + } + try { + if ($type -eq 'SRV') { + Add-DnsServerResourceRecord -SRV -Name $name -ZoneName $zone @srv_args @extra_args -WhatIf:$module.CheckMode + } + else { + Add-DnsServerResourceRecord -Name $name -AllowUpdateAny -ZoneName $zone -TimeToLive $ttl @splat_args -WhatIf:$module.CheckMode @extra_args + } + } + catch { + $module.FailJson("Error adding DNS $type resource $name in zone $zone with value $value", $_) + } + $changes.after += "[$zone] $name $($ttl.TotalSeconds) IN $type $value`n" + } + $module.Result.changed = $true +} + +if ($module.CheckMode) { + # Simulated changes + $module.Diff.before = $changes.before + $module.Diff.after = $changes.after +} +else { + # Real changes + $records_end = Get-DnsServerResourceRecord -ZoneName $zone -Name $name -RRType $type -Node -ErrorAction:Ignore @extra_args | Sort-Object + $module.Diff.before = @( + $records | ForEach-Object { + "[$zone] $($_.HostName) $($_.TimeToLive.TotalSeconds) IN $type $($_.RecordData.$(Get-DnsServerResourceRecordDataPropertyName).ToString())`n" + } + ) -join '' + $module.Diff.after = @( + $records_end | ForEach-Object { + "[$zone] $($_.HostName) $($_.TimeToLive.TotalSeconds) IN $type $($_.RecordData.$(Get-DnsServerResourceRecordDataPropertyName).ToString())`n" + } + ) -join '' +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_dns_record.py b/ansible_collections/community/windows/plugins/modules/win_dns_record.py new file mode 100644 index 00000000..4f3efd4a --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dns_record.py @@ -0,0 +1,192 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021 Sebastian Gruber ,dacoso GmbH All Rights Reserved. +# Copyright: (c) 2019, Hitachi ID Systems, Inc. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_dns_record +short_description: Manage Windows Server DNS records +description: +- Manage DNS records within an existing Windows Server DNS zone. +author: + - Sebastian Gruber (@sgruber94) + - John Nelson (@johnboy2) +requirements: + - This module requires Windows 8, Server 2012, or newer. +options: + name: + description: + - The name of the record. + required: yes + type: str + port: + description: + - The port number of the record. + - Required when C(type=SRV). + - Supported only for C(type=SRV). + type: int + version_added: 1.0.0 + priority: + description: + - The priority number for each service in SRV record. + - Required when C(type=SRV). + - Supported only for C(type=SRV). + type: int + version_added: 1.0.0 + state: + description: + - Whether the record should exist or not. + choices: [ absent, present ] + default: present + type: str + ttl: + description: + - The "time to live" of the record, in seconds. + - Ignored when C(state=absent). + - Valid range is 1 - 31557600. + - Note that an Active Directory forest can specify a minimum TTL, and will + dynamically "round up" other values to that minimum. + default: 3600 + type: int + type: + description: + - The type of DNS record to manage. + - C(SRV) was added in the 1.0.0 release of this collection. + - C(NS) was added in the 1.1.0 release of this collection. + - C(TXT) was added in the 1.6.0 release of this collection. + - C(DHCID) was added in the 1.12.0 release of this collection. + choices: [ A, AAAA, CNAME, DHCID, NS, PTR, SRV, TXT ] + required: yes + type: str + value: + description: + - The value(s) to specify. Required when C(state=present). + - When C(type=PTR) only the partial part of the IP should be given. + - Multiple values can be passed when C(type=NS) + aliases: [ values ] + default: [] + type: list + elements: str + weight: + description: + - Weightage given to each service record in SRV record. + - Required when C(type=SRV). + - Supported only for C(type=SRV). + type: int + version_added: 1.0.0 + zone: + description: + - The name of the zone to manage (eg C(example.com)). + - The zone must already exist. + required: yes + type: str + computer_name: + description: + - Specifies a DNS server. + - You can specify an IP address or any value that resolves to an IP + address, such as a fully qualified domain name (FQDN), host name, or + NETBIOS name. + type: str +''' + +EXAMPLES = r''' +# Demonstrate creating a matching A and PTR record. + +- name: Create database server record + community.windows.win_dns_record: + name: "cgyl1404p.amer.example.com" + type: "A" + value: "10.1.1.1" + zone: "amer.example.com" + +- name: Create matching PTR record + community.windows.win_dns_record: + name: "1.1.1" + type: "PTR" + value: "db1" + zone: "10.in-addr.arpa" + +# Demonstrate replacing an A record with a CNAME + +- name: Remove static record + community.windows.win_dns_record: + name: "db1" + type: "A" + state: absent + zone: "amer.example.com" + +- name: Create database server alias + community.windows.win_dns_record: + name: "db1" + type: "CNAME" + value: "cgyl1404p.amer.example.com" + zone: "amer.example.com" + +# Demonstrate creating multiple A records for the same name + +- name: Create multiple A record values for www + community.windows.win_dns_record: + name: "www" + type: "A" + values: + - 10.0.42.5 + - 10.0.42.6 + - 10.0.42.7 + zone: "example.com" + +# Demonstrates a partial update (replace some existing values with new ones) +# for a pre-existing name + +- name: Update www host with new addresses + community.windows.win_dns_record: + name: "www" + type: "A" + values: + - 10.0.42.5 # this old value was kept (others removed) + - 10.0.42.12 # this new value was added + zone: "example.com" + +# Demonstrate creating a SRV record + +- name: Creating a SRV record with port number and priority + community.windows.win_dns_record: + name: "test" + priority: 5 + port: 995 + state: present + type: "SRV" + weight: 2 + value: "amer.example.com" + zone: "example.com" + +# Demonstrate creating a NS record with multiple values + +- name: Creating NS record + community.windows.win_dns_record: + name: "ansible.prog" + state: present + type: "NS" + values: + - 10.0.0.1 + - 10.0.0.2 + - 10.0.0.3 + - 10.0.0.4 + zone: "example.com" + +# Demonstrate creating a TXT record + +- name: Creating a TXT record with descriptive Text + community.windows.win_dns_record: + name: "test" + state: present + type: "TXT" + value: "justavalue" + zone: "example.com" +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_dns_zone.ps1 b/ansible_collections/community/windows/plugins/modules/win_dns_zone.ps1 new file mode 100644 index 00000000..9b84f9d9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dns_zone.ps1 @@ -0,0 +1,267 @@ +#!powershell + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str"; required = $true } + type = @{ type = "str"; choices = "primary", "secondary", "forwarder", "stub" } + replication = @{ type = "str"; choices = "forest", "domain", "legacy", "none" } + dynamic_update = @{ type = "str"; choices = "secure", "none", "nonsecureandsecure" } + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + forwarder_timeout = @{ type = "int" } + dns_servers = @{ type = "list"; elements = "str" } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$check_mode = $module.CheckMode + +$name = $module.Params.name +$type = $module.Params.type +$replication = $module.Params.replication +$dynamic_update = $module.Params.dynamic_update +$state = $module.Params.state +$dns_servers = $module.Params.dns_servers +$forwarder_timeout = $module.Params.forwarder_timeout + +$parms = @{ name = $name } + +Function Get-DnsZoneObject { + Param([PSObject]$Object) + $parms = @{ + name = $Object.ZoneName.toLower() + type = $Object.ZoneType.toLower() + paused = $Object.IsPaused + shutdown = $Object.IsShutdown + } + + if ($Object.DynamicUpdate) { $parms.dynamic_update = $Object.DynamicUpdate.toLower() } + if ($Object.IsReverseLookupZone) { $parms.reverse_lookup = $Object.IsReverseLookupZone } + if ($Object.ZoneType -like 'forwarder' ) { $parms.forwarder_timeout = $Object.ForwarderTimeout } + if ($Object.MasterServers) { $parms.dns_servers = $Object.MasterServers.IPAddressToString } + if (-not $Object.IsDsIntegrated) { + $parms.replication = "none" + $parms.zone_file = $Object.ZoneFile + } + else { + $parms.replication = $Object.ReplicationScope.toLower() + } + + return $parms | Sort-Object +} + +Function Compare-DnsZone { + Param( + [PSObject]$Original, + [PSObject]$Updated) + + if ($Original -eq $false) { return $false } + $props = @('ZoneType', 'DynamicUpdate', 'IsDsIntegrated', 'MasterServers', 'ForwarderTimeout', 'ReplicationScope') + $x = Compare-Object $Original $Updated -Property $props + $x.Count -eq 0 +} + +# attempt import of module +Try { Import-Module DnsServer } +Catch { $module.FailJson("The DnsServer module failed to load properly: $($_.Exception.Message)", $_) } + +Try { + # determine current zone state + $current_zone = Get-DnsServerZone -name $name + $module.Diff.before = Get-DnsZoneObject -Object $current_zone + if (-not $type) { $type = $current_zone.ZoneType.toLower() } + if ($current_zone.ZoneType -like $type) { $current_zone_type_match = $true } + # check for fast fails + if ($current_zone.ReplicationScope -like 'none' -and $replication -in @('legacy', 'forest', 'domain')) { + $module.FailJson("Converting a file backed DNS zone to Active Directory integrated zone is unsupported") + } + if ($current_zone.ReplicationScope -in @('legacy', 'forest', 'domain') -and $replication -like 'none') { + $module.FailJson("Converting Active Directory integrated zone to a file backed DNS zone is unsupported") + } + if ($current_zone.IsDsIntegrated -eq $false -and $parms.DynamicUpdate -eq 'secure') { + $module.FailJson("The secure dynamic update option is only available for Active Directory integrated zones") + } +} +Catch { + $module.Diff.before = "" + $current_zone = $false +} + +if ($state -eq "present") { + # parse replication/zonefile + if (-not $replication -and $current_zone) { + $parms.ReplicationScope = $current_zone.ReplicationScope + } + elseif ((($replication -eq 'none') -or (-not $replication)) -and (-not $current_zone)) { + $parms.ZoneFile = "$name.dns" + } + elseif (($replication -eq 'none') -and ($current_zone)) { + $parms.ZoneFile = "$name.dns" + } + else { + $parms.ReplicationScope = $replication + } + # parse param + if ($dynamic_update) { $parms.DynamicUpdate = $dynamic_update } + if ($dns_servers) { $parms.MasterServers = $dns_servers } + if ($type -in @('stub', 'forwarder', 'secondary') -and -not $current_zone -and -not $dns_servers) { + $module.FailJson("The dns_servers param is required when creating new stub, forwarder or secondary zones") + } + switch ($type) { + "primary" { + # remove irrelevant params + $parms.Remove('MasterServers') + if ($parms.ZoneFile -and ($dynamic_update -in @('secure', 'nonsecureandsecure'))) { + $parms.Remove('DynamicUpdate') + $module.Warn("Secure DNS updates are available only for Active Directory-integrated zones") + } + if (-not $current_zone) { + # create zone + Try { Add-DnsServerPrimaryZone @parms -WhatIf:$check_mode } + Catch { $module.FailJson("Failed to add $type zone $($name): $($_.Exception.Message)", $_) } + } + else { + # update zone + if (-not $current_zone_type_match) { + Try { + if ($current_zone.ReplicationScope) { + $parms.ReplicationScope = $current_zone.ReplicationScope + } + else { + $parms.Remove('ReplicationScope') + } + if ($current_zone.ZoneFile) { $parms.ZoneFile = $current_zone.ZoneFile } else { $parms.Remove('ReplicationScope') } + if ($current_zone.IsShutdown) { $module.FailJson("Failed to convert DNS zone $($name): this zone is shutdown and cannot be modified") } + ConvertTo-DnsServerPrimaryZone @parms -Force -WhatIf:$check_mode + } + Catch { $module.FailJson("Failed to convert DNS zone $($name): $($_.Exception.Message)", $_) } + } + Try { + if (-not $parms.ZoneFile) { Set-DnsServerPrimaryZone -Name $name -ReplicationScope $parms.ReplicationScope -WhatIf:$check_mode } + if ($dynamic_update) { Set-DnsServerPrimaryZone -Name $name -DynamicUpdate $parms.DynamicUpdate -WhatIf:$check_mode } + } + Catch { $module.FailJson("Failed to set properties on the zone $($name): $($_.Exception.Message)", $_) } + } + } + "secondary" { + # remove irrelevant params + $parms.Remove('ReplicationScope') + $parms.Remove('DynamicUpdate') + if (-not $current_zone) { + # enforce param + $parms.ZoneFile = "$name.dns" + # create zone + Try { Add-DnsServerSecondaryZone @parms -WhatIf:$check_mode } + Catch { $module.FailJson("Failed to add $type zone $($name): $($_.Exception.Message)", $_) } + } + else { + # update zone + if (-not $current_zone_type_match) { + $parms.MasterServers = $current_zone.MasterServers + $parms.ZoneFile = $current_zone.ZoneFile + if ($current_zone.IsShutdown) { $module.FailJson("Failed to convert DNS zone $($name): this zone is shutdown and cannot be modified") } + Try { ConvertTo-DnsServerSecondaryZone @parms -Force -WhatIf:$check_mode } + Catch { $module.FailJson("Failed to convert DNS zone $($name): $($_.Exception.Message)", $_) } + } + Try { if ($dns_servers) { Set-DnsServerSecondaryZone -Name $name -MasterServers $dns_servers -WhatIf:$check_mode } } + Catch { $module.FailJson("Failed to set properties on the zone $($name): $($_.Exception.Message)", $_) } + } + } + "stub" { + $parms.Remove('DynamicUpdate') + if (-not $current_zone) { + # create zone + Try { Add-DnsServerStubZone @parms -WhatIf:$check_mode } + Catch { $module.FailJson("Failed to add $type zone $($name): $($_.Exception.Message)", $_) } + } + else { + # update zone + if (-not $current_zone_type_match) { $module.FailJson("Failed to convert DNS zone $($name) to $type, unsupported conversion") } + Try { + if ($parms.ReplicationScope) { Set-DnsServerStubZone -Name $name -ReplicationScope $parms.ReplicationScope -WhatIf:$check_mode } + if ($forwarder_timeout) { Set-DnsServerStubZone -Name $name -ForwarderTimeout $forwarder_timeout -WhatIf:$check_mode } + if ($dns_servers) { Set-DnsServerStubZone -Name $name -MasterServers $dns_servers -WhatIf:$check_mode } + } + Catch { $module.FailJson("Failed to set properties on the zone $($name): $($_.Exception.Message)", $_) } + } + } + "forwarder" { + # remove irrelevant params + $parms.Remove('DynamicUpdate') + $parms.Remove('ZoneFile') + if ($forwarder_timeout -and ($forwarder_timeout -in 0..15)) { + $parms.ForwarderTimeout = $forwarder_timeout + } + if ($forwarder_timeout -and -not ($forwarder_timeout -in 0..15)) { + $module.Warn("The forwarder_timeout param must be an integer value between 0 and 15") + } + if ($parms.ReplicationScope -eq 'none') { $parms.Remove('ReplicationScope') } + if (-not $current_zone) { + # create zone + Try { Add-DnsServerConditionalForwarderZone @parms -WhatIf:$check_mode } + Catch { $module.FailJson("Failed to add $type zone $($name): $($_.Exception.Message)", $_) } + } + else { + # update zone + if (-not $current_zone_type_match) { $module.FailJson("Failed to convert DNS zone $($name) to $type, unsupported conversion") } + Try { + if ($parms.ReplicationScope) { + Set-DnsServerConditionalForwarderZone -Name $name -ReplicationScope $parms.ReplicationScope -WhatIf:$check_mode + } + if ($forwarder_timeout) { Set-DnsServerConditionalForwarderZone -Name $name -ForwarderTimeout $forwarder_timeout -WhatIf:$check_mode } + if ($dns_servers) { Set-DnsServerConditionalForwarderZone -Name $name -MasterServers $dns_servers -WhatIf:$check_mode } + } + Catch { $module.FailJson("Failed to set properties on the zone $($name): $($_.Exception.Message)", $_) } + } + } + } +} + +if ($state -eq "absent") { + if ($current_zone -and -not $check_mode) { + Try { + Remove-DnsServerZone -Name $name -Force -WhatIf:$check_mode + $module.Result.changed = $true + $module.Diff.after = "" + } + Catch { + $module.FailJson("Failed to remove DNS zone: $($_.Exception.Message)", $_) + } + } + $module.ExitJson() +} + +# determine if a change was made +Try { + $new_zone = Get-DnsServerZone -Name $name + if (-not (Compare-DnsZone -Original $current_zone -Updated $new_zone)) { + $module.Result.changed = $true + $module.Result.zone = Get-DnsZoneObject -Object $new_zone + $module.Diff.after = Get-DnsZoneObject -Object $new_zone + } + + # simulate changes if check mode + if ($check_mode) { + $new_zone = @{} + $current_zone.PSObject.Properties | ForEach-Object { + if ($parms[$_.Name]) { + $new_zone[$_.Name] = $parms[$_.Name] + } + else { + $new_zone[$_.Name] = $_.Value + } + } + $module.Diff.after = Get-DnsZoneObject -Object $new_zone + } +} +Catch { + $module.FailJson("Failed to lookup new zone $($name): $($_.Exception.Message)", $_) +} + +$module.ExitJson()
\ No newline at end of file diff --git a/ansible_collections/community/windows/plugins/modules/win_dns_zone.py b/ansible_collections/community/windows/plugins/modules/win_dns_zone.py new file mode 100644 index 00000000..194a3fc2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dns_zone.py @@ -0,0 +1,182 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_dns_zone +short_description: Manage Windows Server DNS Zones +author: Joe Zollo (@joezollo) +requirements: + - This module requires Windows Server 2012R2 or Newer +description: + - Manage Windows Server DNS Zones + - Adds, Removes and Modifies DNS Zones - Primary, Secondary, Forwarder & Stub + - Task should be delegated to a Windows DNS Server +options: + name: + description: + - Fully qualified name of the DNS zone. + type: str + required: true + type: + description: + - Specifies the type of DNS zone. + - When l(type=secondary), the DNS server will immediately attempt to + perform a zone transfer from the servers in this list. If this initial + transfer fails, then the zone will be left in an unworkable state. + This module does not verify the initial transfer. + type: str + choices: [ primary, secondary, stub, forwarder ] + dynamic_update: + description: + - Specifies how a zone handles dynamic updates. + - Secure DNS updates are available only for Active Directory-integrated + zones. + - When not specified during new zone creation, Windows will default this + to l(none). + type: str + choices: [ secure, none, nonsecureandsecure ] + state: + description: + - Specifies the desired state of the DNS zone. + - When l(state=present) the module will attempt to create the specified + DNS zone if it does not already exist. + - When l(state=absent), the module will remove the specified DNS + zone and all subsequent DNS records. + type: str + default: present + choices: [ present, absent ] + forwarder_timeout: + description: + - Specifies a length of time, in seconds, that a DNS server waits for a + remote DNS server to resolve a query. + - Accepts integer values between 0 and 15. + - If the provided value is not valid, it will be omitted and a warning + will be issued. + type: int + replication: + description: + - Specifies the replication scope for the DNS zone. + - l(replication=forest) will replicate the DNS zone to all domain + controllers in the Active Directory forest. + - l(replication=domain) will replicate the DNS zone to all domain + controllers in the Active Directory domain. + - l(replication=none) disables Active Directory integration and + creates a local file with the name of the zone. + - This is the equivalent of selecting l(store the zone in Active + Directory) in the GUI. + type: str + choices: [ forest, domain, legacy, none ] + dns_servers: + description: + - Specifies an list of IP addresses of the primary servers of the zone. + - DNS queries for a forwarded zone are sent to primary servers. + - Required if l(type=secondary), l(type=forwarder) or l(type=stub), + otherwise ignored. + - At least one server is required. + elements: str + type: list +''' + +EXAMPLES = r''' +- name: Ensure primary zone is present + community.windows.win_dns_zone: + name: wpinner.euc.vmware.com + replication: domain + type: primary + state: present + +- name: Ensure DNS zone is absent + community.windows.win_dns_zone: + name: jamals.euc.vmware.com + state: absent + +- name: Ensure forwarder has specific DNS servers + community.windows.win_dns_zone: + name: jamals.euc.vmware.com + type: forwarder + dns_servers: + - 10.245.51.100 + - 10.245.51.101 + - 10.245.51.102 + +- name: Ensure stub zone has specific DNS servers + community.windows.win_dns_zone: + name: virajp.euc.vmware.com + type: stub + dns_servers: + - 10.58.2.100 + - 10.58.2.101 + +- name: Ensure stub zone is converted to a secondary zone + community.windows.win_dns_zone: + name: virajp.euc.vmware.com + type: secondary + +- name: Ensure secondary zone is present with no replication + community.windows.win_dns_zone: + name: dgemzer.euc.vmware.com + type: secondary + replication: none + dns_servers: + - 10.19.20.1 + +- name: Ensure secondary zone is converted to a primary zone + community.windows.win_dns_zone: + name: dgemzer.euc.vmware.com + type: primary + replication: none + dns_servers: + - 10.19.20.1 + +- name: Ensure primary DNS zone is present without replication + community.windows.win_dns_zone: + name: basavaraju.euc.vmware.com + replication: none + type: primary + +- name: Ensure primary DNS zone has nonsecureandsecure dynamic updates enabled + community.windows.win_dns_zone: + name: basavaraju.euc.vmware.com + replication: none + dynamic_update: nonsecureandsecure + type: primary + +- name: Ensure DNS zone is absent + community.windows.win_dns_zone: + name: marshallb.euc.vmware.com + state: absent + +- name: Ensure DNS zones are absent + community.windows.win_dns_zone: + name: "{{ item }}" + state: absent + loop: + - jamals.euc.vmware.com + - dgemzer.euc.vmware.com + - wpinner.euc.vmware.com + - marshallb.euc.vmware.com + - basavaraju.euc.vmware.com +''' + +RETURN = r''' +zone: + description: New/Updated DNS zone parameters + returned: When l(state=present) + type: dict + sample: + name: + type: + dynamic_update: + reverse_lookup: + forwarder_timeout: + paused: + shutdown: + zone_file: + replication: + dns_servers: +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_computer.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_computer.ps1 new file mode 100644 index 00000000..ee2b16ea --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_computer.ps1 @@ -0,0 +1,316 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer (@briantist) +# Copyright: (c) 2017, AMTEGA - Xunta de Galicia +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + + +# ------------------------------------------------------------------------------ +$ErrorActionPreference = "Stop" + +# Preparing result +$result = @{} +$result.changed = $false + +# Parameter ingestion +$params = Parse-Args $args -supports_check_mode $true + +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false +$temp = Get-AnsibleParam -obj $params -name '_ansible_remote_tmp' -type 'path' -default $env:TEMP + +$name = Get-AnsibleParam -obj $params -name "name" -failifempty $true -resultobj $result +$sam_account_name = Get-AnsibleParam -obj $params -name "sam_account_name" -default "${name}$" +If (-not $sam_account_name.EndsWith("$")) { + $sam_account_name = "${sam_account_name}$" +} +$enabled = Get-AnsibleParam -obj $params -name "enabled" -type "bool" -default $true +$description = Get-AnsibleParam -obj $params -name "description" -default $null +$domain_username = Get-AnsibleParam -obj $params -name "domain_username" -type "str" +$domain_password = Get-AnsibleParam -obj $params -name "domain_password" -type "str" -failifempty ($null -ne $domain_username) +$domain_server = Get-AnsibleParam -obj $params -name "domain_server" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -ValidateSet "present", "absent" -default "present" +$managed_by = Get-AnsibleParam -obj $params -name "managed_by" -type "str" + +$odj_action = Get-AnsibleParam -obj $params -name "offline_domain_join" -type "str" -ValidateSet "none", "output", "path" -default "none" +$_default_blob_path = Join-Path -Path $temp -ChildPath ([System.IO.Path]::GetRandomFileName()) +$odj_blob_path = Get-AnsibleParam -obj $params -name "odj_blob_path" -type "str" -default $_default_blob_path + +$extra_args = @{} +if ($null -ne $domain_username) { + $domain_password = ConvertTo-SecureString $domain_password -AsPlainText -Force + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $domain_username, $domain_password + $extra_args.Credential = $credential +} +if ($null -ne $domain_server) { + $extra_args.Server = $domain_server +} + +If ($state -eq "present") { + $dns_hostname = Get-AnsibleParam -obj $params -name "dns_hostname" -failifempty $true -resultobj $result + $ou = Get-AnsibleParam -obj $params -name "ou" -failifempty $true -resultobj $result + $distinguished_name = "CN=$name,$ou" + + $desired_state = [ordered]@{ + name = $name + sam_account_name = $sam_account_name + dns_hostname = $dns_hostname + ou = $ou + distinguished_name = $distinguished_name + description = $description + enabled = $enabled + state = $state + managed_by = $managed_by + } +} +Else { + $desired_state = [ordered]@{ + name = $name + sam_account_name = $sam_account_name + state = $state + } +} + +# ------------------------------------------------------------------------------ +Function Get-InitialState($desired_state) { + # Test computer exists + $computer = Try { + Get-ADComputer ` + -Identity $desired_state.sam_account_name ` + -Properties DistinguishedName, DNSHostName, Enabled, Name, SamAccountName, Description, ObjectClass, ManagedBy ` + @extra_args + } + Catch { $null } + If ($computer) { + $null, $current_ou = $computer.DistinguishedName -split '(?<=[^\\](?:\\\\)*),' + $current_ou = $current_ou -join ',' + + $initial_state = [ordered]@{ + name = $computer.Name + sam_account_name = $computer.SamAccountName + dns_hostname = $computer.DNSHostName + ou = $current_ou + distinguished_name = $computer.DistinguishedName + description = $computer.Description + enabled = $computer.Enabled + state = "present" + managed_by = $computer.ManagedBy + } + } + Else { + $initial_state = [ordered]@{ + name = $desired_state.name + sam_account_name = $desired_state.sam_account_name + state = "absent" + } + } + + return $initial_state +} + +# ------------------------------------------------------------------------------ +Function Set-ConstructedState($initial_state, $desired_state) { + Try { + Set-ADComputer ` + -Identity $desired_state.name ` + -SamAccountName $desired_state.name ` + -DNSHostName $desired_state.dns_hostname ` + -Enabled $desired_state.enabled ` + -Description $desired_state.description ` + -ManagedBy $desired_state.managed_by ` + -WhatIf:$check_mode ` + @extra_args + } + Catch { + Fail-Json -obj $result -message "Failed to set the AD object $($desired_state.name): $($_.Exception.Message)" + } + + If ($initial_state.distinguished_name -cne $desired_state.distinguished_name) { + # Move computer to OU + Try { + Get-ADComputer -Identity $desired_state.sam_account_name @extra_args | + Move-ADObject ` + -TargetPath $desired_state.ou ` + -Confirm:$False ` + -WhatIf:$check_mode ` + @extra_args + } + Catch { + $msg = "Failed to move the AD object $($initial_state.distinguished_name) to $($desired_state.distinguished_name): $($_.Exception.Message)" + Fail-Json -obj $result -message $msg + } + } + $result.changed = $true +} + +# ------------------------------------------------------------------------------ +Function Add-ConstructedState($desired_state) { + Try { + New-ADComputer ` + -Name $desired_state.name ` + -SamAccountName $desired_state.sam_account_name ` + -DNSHostName $desired_state.dns_hostname ` + -Path $desired_state.ou ` + -Enabled $desired_state.enabled ` + -Description $desired_state.description ` + -ManagedBy $desired_state.managed_by ` + -WhatIf:$check_mode ` + @extra_args + } + Catch { + Fail-Json -obj $result -message "Failed to create the AD object $($desired_state.name): $($_.Exception.Message)" + } + + $result.changed = $true +} + +Function Invoke-OfflineDomainJoin { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $desired_state , + + [Parameter(Mandatory = $true)] + [ValidateSet('none', 'output', 'path')] + [String] + $Action , + + [Parameter()] + [System.IO.FileInfo] + $BlobPath + ) + + End { + if ($Action -eq 'none') { + return + } + + $dns_domain = $desired_state.dns_hostname -replace '^[^.]+\.' + + $output = $Action -eq 'output' + + $arguments = @( + 'djoin.exe' + '/PROVISION' + '/REUSE' # we're pre-creating the machine normally to set other fields, then overwriting it with this + '/DOMAIN' + $dns_domain + '/MACHINE' + $desired_state.sam_account_name.TrimEnd('$') # this machine name is the short name + '/MACHINEOU' + $desired_state.ou + '/SAVEFILE' + $BlobPath.FullName + ) + + $invocation = Argv-ToString -arguments $arguments + $result.djoin = @{ + invocation = $invocation + } + $result.odj_blob = '' + + if ($Action -eq 'path') { + $result.odj_blob_path = $BlobPath.FullName + } + + if (-not $BlobPath.Directory.Exists) { + Fail-Json -obj $result -message "BLOB path directory '$($BlobPath.Directory.FullName)' doesn't exist." + } + + if ($PSCmdlet.ShouldProcess($argstring)) { + try { + $djoin_result = Run-Command -command $invocation + $result.djoin.rc = $djoin_result.rc + $result.djoin.stdout = $djoin_result.stdout + $result.djoin.stderr = $djoin_result.stderr + + if ($djoin_result.rc) { + Fail-Json -obj $result -message "Problem running djoin.exe. See returned values." + } + + if ($output) { + $bytes = [System.IO.File]::ReadAllBytes($BlobPath.FullName) + $data = [Convert]::ToBase64String($bytes) + $result.odj_blob = $data + } + } + finally { + if ($output -and $BlobPath.Exists) { + $BlobPath.Delete() + } + } + } + } +} + +# ------------------------------------------------------------------------------ +Function Remove-ConstructedState($initial_state) { + Try { + Get-ADComputer -Identity $initial_state.sam_account_name @extra_args | + Remove-ADObject ` + -Recursive ` + -Confirm:$False ` + -WhatIf:$check_mode ` + @extra_args + } + Catch { + Fail-Json -obj $result -message "Failed to remove the AD object $($desired_state.name): $($_.Exception.Message)" + } + + $result.changed = $true +} + +# ------------------------------------------------------------------------------ +Function Test-HashtableEquality($x, $y) { + # Compare not nested HashTables + Foreach ($key in $x.Keys) { + If (($y.Keys -notcontains $key) -or ($x[$key] -cne $y[$key])) { + Return $false + } + } + foreach ($key in $y.Keys) { + if (($x.Keys -notcontains $key) -or ($x[$key] -cne $y[$key])) { + Return $false + } + } + Return $true +} + +# ------------------------------------------------------------------------------ +$initial_state = Get-InitialState($desired_state) + +If ($desired_state.state -eq "present") { + If ($initial_state.state -eq "present") { + $in_desired_state = Test-HashtableEquality -X $initial_state -Y $desired_state + + If (-not $in_desired_state) { + Set-ConstructedState -initial_state $initial_state -desired_state $desired_state + } + } + Else { + # $desired_state.state = "Present" & $initial_state.state = "Absent" + Add-ConstructedState -desired_state $desired_state + Invoke-OfflineDomainJoin -desired_state $desired_state -Action $odj_action -BlobPath $odj_blob_path -WhatIf:$check_mode + } +} +Else { + # $desired_state.state = "Absent" + If ($initial_state.state -eq "present") { + Remove-ConstructedState -initial_state $initial_state + } +} + +If ($diff_support) { + $diff = @{ + before = $initial_state + after = $desired_state + } + $result.diff = $diff +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_computer.py b/ansible_collections/community/windows/plugins/modules/win_domain_computer.py new file mode 100644 index 00000000..8e759c3d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_computer.py @@ -0,0 +1,210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer (@briantist) +# Copyright: (c) 2017, AMTEGA - Xunta de Galicia +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_computer +short_description: Manage computers in Active Directory +description: + - Create, read, update and delete computers in Active Directory using a + windows bridge computer to launch New-ADComputer, Get-ADComputer, + Set-ADComputer, Remove-ADComputer and Move-ADObject powershell commands. +options: + name: + description: + - Specifies the name of the object. + - This parameter sets the Name property of the Active Directory object. + - The LDAP display name (ldapDisplayName) of this property is name. + type: str + required: true + sam_account_name: + description: + - Specifies the Security Account Manager (SAM) account name of the + computer. + - It maximum is 256 characters, 15 is advised for older + operating systems compatibility. + - The LDAP display name (ldapDisplayName) for this property is sAMAccountName. + - If ommitted the value is the same as C(name). + - Note that all computer SAMAccountNames need to end with a C($). + - If C($) is omitted, it will be added to the end. + type: str + enabled: + description: + - Specifies if an account is enabled. + - An enabled account requires a password. + - This parameter sets the Enabled property for an account object. + - This parameter also sets the ADS_UF_ACCOUNTDISABLE flag of the + Active Directory User Account Control (UAC) attribute. + type: bool + default: yes + ou: + description: + - Specifies the X.500 path of the Organizational Unit (OU) or container + where the new object is created. Required when I(state=present). + - "Special characters must be escaped, + see L(Distinguished Names,https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names) for details." + type: str + description: + description: + - Specifies a description of the object. + - This parameter sets the value of the Description property for the object. + - The LDAP display name (ldapDisplayName) for this property is description. + type: str + default: '' + dns_hostname: + description: + - Specifies the fully qualified domain name (FQDN) of the computer. + - This parameter sets the DNSHostName property for a computer object. + - The LDAP display name for this property is dNSHostName. + - Required when I(state=present). + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user Ansible used to log in with will be + used instead when using CredSSP or Kerberos with credential delegation. + type: str + domain_password: + description: + - The password for I(username). + type: str + domain_server: + description: + - Specifies the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the domain of the computer + running PowerShell. + type: str + state: + description: + - Specified whether the computer should be C(present) or C(absent) in + Active Directory. + type: str + choices: [ absent, present ] + default: present + managed_by: + description: + - The value to be assigned to the LDAP C(managedBy) attribute. + - This value can be in the forms C(Distinguished Name), C(objectGUID), + C(objectSid) or C(sAMAccountName), see examples for more details. + type: str + version_added: '1.3.0' + offline_domain_join: + description: + - Provisions a computer in the directory and provides a BLOB file that can be used on the target computer/image to join it to the domain while offline. + - The C(none) value doesn't do any offline join operations. + - C(output) returns the BLOB in output. The BLOB should be treated as secret (it contains the machine password) so use C(no_log) when using this option. + - C(path) preserves the offline domain join BLOB file on the target machine for later use. The path will be returned. + - If the computer already exists, no BLOB will be created/returned, and the module will operate as it would have without offline domain join. + type: str + choices: + - none + - output + - path + default: none + odj_blob_path: + description: + - The path to the file where the BLOB will be saved. If omitted, a temporary file will be used. + - If I(offline_domain_join=output) the file will be deleted after its contents are returned. + - The parent directory for the BLOB file must exist; intermediate directories will not be created. +notes: + - "For more information on Offline Domain Join + see L(the step-by-step guide,https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/dd392267%28v=ws.10%29)." + - When using the ODJ BLOB to join a computer to the domain, it must be written out to a file. + - The file must be UTF-16 encoded (in PowerShell this encoding is called C(Unicode)), and it must end in a null character. See examples. + - The C(djoin.exe) part of the offline domain join process will not use I(domain_server), I(domain_username), or I(domain_password). +seealso: +- module: ansible.windows.win_domain +- module: ansible.windows.win_domain_controller +- module: community.windows.win_domain_group +- module: ansible.windows.win_domain_membership +- module: community.windows.win_domain_user +author: +- Daniel Sánchez Fábregas (@Daniel-Sanchez-Fabregas) +- Brian Scholer (@briantist) +''' + +EXAMPLES = r''' + - name: Add linux computer to Active Directory OU using a windows machine + community.windows.win_domain_computer: + name: one_linux_server + sam_account_name: linux_server$ + dns_hostname: one_linux_server.my_org.local + ou: "OU=servers,DC=my_org,DC=local" + description: Example of linux server + enabled: yes + state: present + delegate_to: my_windows_bridge.my_org.local + + - name: Remove linux computer from Active Directory using a windows machine + community.windows.win_domain_computer: + name: one_linux_server + state: absent + delegate_to: my_windows_bridge.my_org.local + + - name: Provision a computer for offline domain join + community.windows.win_domain_computer: + name: newhost + dns_hostname: newhost.ansible.local + ou: 'OU=A great\, big organizational unit name,DC=ansible,DC=local' + state: present + offline_domain_join: yes + odj_return_blob: yes + register: computer_status + delegate_to: windc.ansible.local + + - name: Join a workgroup computer to the domain + vars: + target_blob_file: 'C:\ODJ\blob.txt' + ansible.windows.win_shell: | + $blob = [Convert]::FromBase64String('{{ computer_status.odj_blob }}') + [IO.File]::WriteAllBytes('{{ target_blob_file }}', $blob) + & djoin.exe --% /RequestODJ /LoadFile '{{ target_blob_file }}' /LocalOS /WindowsPath "%SystemRoot%" + + - name: Restart to complete domain join + ansible.windows.win_restart: +''' + +RETURN = r''' +odj_blob: + description: + - The offline domain join BLOB. This is an empty string when in check mode or when offline_domain_join is 'path'. + - This field contains the base64 encoded raw bytes of the offline domain join BLOB file. + returned: when offline_domain_join is not 'none' and the computer didn't exist + type: str + sample: <a long base64 string> +odj_blob_file: + description: The path to the offline domain join BLOB file on the target host. If odj_blob_path was specified, this will match that path. + returned: when offline_domain_join is 'path' and the computer didn't exist + type: str + sample: 'C:\Users\admin\AppData\Local\Temp\e4vxonty.rkb' +djoin: + description: Information about the invocation of djoin.exe. + returned: when offline_domain_join is True and the computer didn't exist + type: dict + contains: + invocation: + description: The full command line used to call djoin.exe + type: str + returned: always + sample: djoin.exe /PROVISION /MACHINE compname /MACHINEOU OU=Hosts,DC=ansible,DC=local /DOMAIN ansible.local /SAVEFILE blobfile.txt + rc: + description: The return code from djoin.exe + type: int + returned: when not check mode + sample: 87 + stdout: + description: The stdout from djoin.exe + type: str + returned: when not check mode + sample: Computer provisioning completed successfully. + stderr: + description: The stderr from djoin.exe + type: str + returned: when not check mode + sample: Invalid input parameter combination. +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_group.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_group.ps1 new file mode 100644 index 00000000..d9373766 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_group.ps1 @@ -0,0 +1,371 @@ +#!powershell + +# Copyright: (c) 2017, Jordan Borean <jborean93@gmail.com>, and others +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$display_name = Get-AnsibleParam -obj $params -name "display_name" -type "str" +$domain_username = Get-AnsibleParam -obj $params -name "domain_username" -type "str" +$domain_password = Get-AnsibleParam -obj $params -name "domain_password" -type "str" -failifempty ($null -ne $domain_username) +$description = Get-AnsibleParam -obj $params -name "description" -type "str" +$category = Get-AnsibleParam -obj $params -name "category" -type "str" -validateset "distribution", "security" +$scope = Get-AnsibleParam -obj $params -name "scope" -type "str" -validateset "domainlocal", "global", "universal" +$managed_by = Get-AnsibleParam -obj $params -name "managed_by" -type "str" +$attributes = Get-AnsibleParam -obj $params -name "attributes" +$organizational_unit = Get-AnsibleParam -obj $params -name "organizational_unit" -type "str" -aliases "ou", "path" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent" +$protect = Get-AnsibleParam -obj $params -name "protect" -type "bool" +$ignore_protection = Get-AnsibleParam -obj $params -name "ignore_protection" -type "bool" -default $false +$domain_server = Get-AnsibleParam -obj $params -name "domain_server" -type "str" + +$result = @{ + changed = $false + created = $false +} + +if ($diff_mode) { + $result.diff = @{} +} + +if (-not (Get-Module -Name ActiveDirectory -ListAvailable)) { + Fail-Json $result "win_domain_group requires the ActiveDirectory PS module to be installed" +} +Import-Module ActiveDirectory + +$extra_args = @{} +if ($null -ne $domain_username) { + $domain_password = ConvertTo-SecureString $domain_password -AsPlainText -Force + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $domain_username, $domain_password + $extra_args.Credential = $credential +} +if ($null -ne $domain_server) { + $extra_args.Server = $domain_server +} + +try { + $group = Get-ADGroup -Identity $name -Properties * @extra_args +} +catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + $group = $null +} +catch { + Fail-Json $result "failed to retrieve initial details for group $($name): $($_.Exception.Message)" +} +if ($state -eq "absent") { + if ($null -ne $group) { + if ($group.ProtectedFromAccidentalDeletion -eq $true -and $ignore_protection -eq $true) { + $group = $group | Set-ADObject -ProtectedFromAccidentalDeletion $false -WhatIf:$check_mode -PassThru @extra_args + } + elseif ($group.ProtectedFromAccidentalDeletion -eq $true -and $ignore_protection -eq $false) { + $msg = -join @( + "cannot delete group $name when ProtectedFromAccidentalDeletion is turned on, " + "run this module with ignore_protection=true to override this" + ) + Fail-Json $result $msg + } + + try { + $group | Remove-ADGroup -Confirm:$false -WhatIf:$check_mode @extra_args + } + catch { + Fail-Json $result "failed to remove group $($name): $($_.Exception.Message)" + } + + $result.changed = $true + if ($diff_mode) { + $result.diff.prepared = "-[$name]" + } + } +} +else { + # validate that path is an actual path + if ($null -ne $organizational_unit) { + try { + Get-ADObject -Identity $organizational_unit @extra_args | Out-Null + } + catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + Fail-Json $result "the group path $organizational_unit does not exist, please specify a valid LDAP path" + } + } + + $diff_text = $null + if ($null -ne $group) { + # will be overridden later if no change actually occurs + $diff_text += "[$name]`n" + + # change the path of the group + if ($null -ne $organizational_unit) { + $group_cn = $group.CN + $existing_path = $group.DistinguishedName -replace "^CN=$group_cn,", '' + if ($existing_path -ne $organizational_unit) { + $protection_disabled = $false + if ($group.ProtectedFromAccidentalDeletion -eq $true -and $ignore_protection -eq $true) { + $group | Set-ADObject -ProtectedFromAccidentalDeletion $false -WhatIf:$check_mode -PassThru @extra_args | Out-Null + $protection_disabled = $true + } + elseif ($group.ProtectedFromAccidentalDeletion -eq $true -and $ignore_protection -eq $false) { + $msg = -join @( + "cannot move group $name when ProtectedFromAccidentalDeletion is turned on, " + "run this module with ignore_protection=true to override this" + ) + Fail-Json $result $msg + } + + try { + $group = $group | Move-ADObject -Targetpath $organizational_unit -WhatIf:$check_mode -PassThru @extra_args + } + catch { + Fail-Json $result "failed to move group from $existing_path to $($organizational_unit): $($_.Exception.Message)" + } + finally { + if ($protection_disabled -eq $true) { + $group | Set-ADObject -ProtectedFromAccidentalDeletion $true -WhatIf:$check_mode -PassThru @extra_args | Out-Null + } + } + + $result.changed = $true + $diff_text += "-DistinguishedName = CN=$group_cn,$existing_path`n+DistinguishedName = CN=$group_cn,$organizational_unit`n" + + if ($protection_disabled -eq $true) { + $group | Set-ADObject -ProtectedFromAccidentalDeletion $true -WhatIf:$check_mode @extra_args | Out-Null + } + # get the group again once we have moved it + $group = Get-ADGroup -Identity $name -Properties * @extra_args + } + } + + # change attributes of group + $extra_scope_change = $null + $run_change = $false + $set_args = $extra_args.Clone() + + if ($null -ne $scope) { + if ($group.GroupScope -ne $scope) { + # you cannot from from Global to DomainLocal and vice-versa, we + # need to change it to Universal and then finally to the target + # scope + if ($group.GroupScope -eq "global" -and $scope -eq "domainlocal") { + $set_args.GroupScope = "Universal" + $extra_scope_change = $scope + } + elseif ($group.GroupScope -eq "domainlocal" -and $scope -eq "global") { + $set_args.GroupScope = "Universal" + $extra_scope_change = $scope + } + else { + $set_args.GroupScope = $scope + } + $run_change = $true + $diff_text += "-GroupScope = $($group.GroupScope)`n+GroupScope = $scope`n" + } + } + + if ($null -ne $description -and $group.Description -cne $description) { + $set_args.Description = $description + $run_change = $true + $diff_text += "-Description = $($group.Description)`n+Description = $description`n" + } + + if ($null -ne $display_name -and $group.DisplayName -cne $display_name) { + $set_args.DisplayName = $display_name + $run_change = $true + $diff_text += "-DisplayName = $($group.DisplayName)`n+DisplayName = $display_name`n" + } + + if ($null -ne $category -and $group.GroupCategory -ne $category) { + $set_args.GroupCategory = $category + $run_change = $true + $diff_text += "-GroupCategory = $($group.GroupCategory)`n+GroupCategory = $category`n" + } + + if ($null -ne $managed_by) { + if ($null -eq $group.ManagedBy) { + $set_args.ManagedBy = $managed_by + $run_change = $true + $diff_text += "+ManagedBy = $managed_by`n" + } + else { + try { + $managed_by_object = Get-ADGroup -Identity $managed_by @extra_args + } + catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + try { + $managed_by_object = Get-ADUser -Identity $managed_by @extra_args + } + catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + Fail-Json $result "failed to find managed_by user or group $managed_by to be used for comparison" + } + } + + if ($group.ManagedBy -ne $managed_by_object.DistinguishedName) { + $set_args.ManagedBy = $managed_by + $run_change = $true + $diff_text += "-ManagedBy = $($group.ManagedBy)`n+ManagedBy = $($managed_by_object.DistinguishedName)`n" + } + } + } + + if ($null -ne $attributes) { + $add_attributes = @{} + $replace_attributes = @{} + foreach ($attribute in $attributes.GetEnumerator()) { + $attribute_name = $attribute.Name + $attribute_value = $attribute.Value + + $valid_property = [bool]($group.PSobject.Properties.name -eq $attribute_name) + if ($valid_property) { + $existing_value = $group.$attribute_name + if ($existing_value -cne $attribute_value) { + $replace_attributes.$attribute_name = $attribute_value + $diff_text += "-$attribute_name = $existing_value`n+$attribute_name = $attribute_value`n" + } + } + else { + $add_attributes.$attribute_name = $attribute_value + $diff_text += "+$attribute_name = $attribute_value`n" + } + } + if ($add_attributes.Count -gt 0) { + $set_args.Add = $add_attributes + $run_change = $true + } + if ($replace_attributes.Count -gt 0) { + $set_args.Replace = $replace_attributes + $run_change = $true + } + } + + if ($run_change) { + try { + $group = $group | Set-ADGroup -WhatIf:$check_mode -PassThru @set_args + } + catch { + Fail-Json $result "failed to change group $($name): $($_.Exception.Message)" + } + $result.changed = $true + + if ($null -ne $extra_scope_change) { + try { + $group = $group | Set-ADGroup -GroupScope $extra_scope_change -WhatIf:$check_mode -PassThru @extra_args + } + catch { + Fail-Json $result "failed to change scope of group $name to $($scope): $($_.Exception.Message)" + } + } + } + + # make sure our diff text is null if no change occurred + if ($result.changed -eq $false) { + $diff_text = $null + } + } + else { + # validate if scope is set + if ($null -eq $scope) { + Fail-Json $result "scope must be set when state=present and the group doesn't exist" + } + + $diff_text += "+[$name]`n+Scope = $scope`n" + $add_args = $extra_args.Clone() + $add_args.Name = $name + $add_args.GroupScope = $scope + + if ($null -ne $description) { + $add_args.Description = $description + $diff_text += "+Description = $description`n" + } + + if ($null -ne $display_name) { + $add_args.DisplayName = $display_name + $diff_text += "+DisplayName = $display_name`n" + } + + if ($null -ne $category) { + $add_args.GroupCategory = $category + $diff_text += "+GroupCategory = $category`n" + } + + if ($null -ne $managed_by) { + $add_args.ManagedBy = $managed_by + $diff_text += "+ManagedBy = $managed_by`n" + } + + if ($null -ne $attributes) { + $add_args.OtherAttributes = $attributes + foreach ($attribute in $attributes.GetEnumerator()) { + $diff_text += "+$($attribute.Name) = $($attribute.Value)`n" + } + } + + if ($null -ne $organizational_unit) { + $add_args.Path = $organizational_unit + $diff_text += "+Path = $organizational_unit`n" + } + + try { + $group = New-AdGroup -WhatIf:$check_mode -PassThru @add_args + } + catch { + Fail-Json $result "failed to create group $($name): $($_.Exception.Message)" + } + $result.changed = $true + $result.created = $true + } + + # set the protection value + if ($null -ne $protect) { + if (-not $check_mode) { + $group = Get-ADGroup -Identity $name -Properties * @extra_args + } + $existing_protection_value = $group.ProtectedFromAccidentalDeletion + if ($null -eq $existing_protection_value) { + $existing_protection_value = $false + } + if ($existing_protection_value -ne $protect) { + $diff_text += @" +-ProtectedFromAccidentalDeletion = $existing_protection_value ++ProtectedFromAccidentalDeletion = $protect +"@ + + $group | Set-ADObject -ProtectedFromAccidentalDeletion $protect -WhatIf:$check_mode -PassThru @extra_args + $result.changed = $true + } + } + + if ($diff_mode -and $null -ne $diff_text) { + $result.diff.prepared = $diff_text + } + + if (-not $check_mode) { + $group = Get-ADGroup -Identity $name -Properties * @extra_args + $result.sid = $group.SID.Value + $result.description = $group.Description + $result.distinguished_name = $group.DistinguishedName + $result.display_name = $group.DisplayName + $result.name = $group.Name + $result.canonical_name = $group.CanonicalName + $result.guid = $group.ObjectGUID + $result.protected_from_accidental_deletion = $group.ProtectedFromAccidentalDeletion + $result.managed_by = $group.ManagedBy + $result.group_scope = ($group.GroupScope).ToString() + $result.category = ($group.GroupCategory).ToString() + + if ($null -ne $attributes) { + $result.attributes = @{} + foreach ($attribute in $attributes.GetEnumerator()) { + $attribute_name = $attribute.Name + $result.attributes.$attribute_name = $group.$attribute_name + } + } + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_group.py b/ansible_collections/community/windows/plugins/modules/win_domain_group.py new file mode 100644 index 00000000..b761055e --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_group.py @@ -0,0 +1,236 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_group +short_description: Creates, modifies or removes domain groups +description: +- Creates, modifies or removes groups in Active Directory. +- For local groups, use the M(ansible.windows.win_group) module instead. +options: + attributes: + description: + - A dict of custom LDAP attributes to set on the group. + - This can be used to set custom attributes that are not exposed as module + parameters, e.g. C(mail). + - See the examples on how to format this parameter. + type: dict + category: + description: + - The category of the group, this is the value to assign to the LDAP + C(groupType) attribute. + - If a new group is created then C(security) will be used by default. + type: str + choices: [ distribution, security ] + description: + description: + - The value to be assigned to the LDAP C(description) attribute. + type: str + display_name: + description: + - The value to assign to the LDAP C(displayName) attribute. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user Ansible used to log in with will be + used instead. + type: str + domain_password: + description: + - The password for C(username). + type: str + domain_server: + description: + - Specifies the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the domain of the computer + running PowerShell. + type: str + ignore_protection: + description: + - Will ignore the C(ProtectedFromAccidentalDeletion) flag when deleting or + moving a group. + - The module will fail if one of these actions need to occur and this value + is set to C(no). + type: bool + default: no + managed_by: + description: + - The value to be assigned to the LDAP C(managedBy) attribute. + - This value can be in the forms C(Distinguished Name), C(objectGUID), + C(objectSid) or C(sAMAccountName), see examples for more details. + type: str + name: + description: + - The name of the group to create, modify or remove. + - This value can be in the forms C(Distinguished Name), C(objectGUID), + C(objectSid) or C(sAMAccountName), see examples for more details. + type: str + required: yes + organizational_unit: + description: + - The full LDAP path to create or move the group to. + - This should be the path to the parent object to create or move the group to. + - See examples for details of how this path is formed. + type: str + aliases: [ ou, path ] + protect: + description: + - Will set the C(ProtectedFromAccidentalDeletion) flag based on this value. + - This flag stops a user from deleting or moving a group to a different + path. + type: bool + scope: + description: + - The scope of the group. + - If C(state=present) and the group doesn't exist then this must be set. + type: str + choices: [domainlocal, global, universal] + state: + description: + - If C(state=present) this module will ensure the group is created and is + configured accordingly. + - If C(state=absent) this module will delete the group if it exists + type: str + choices: [ absent, present ] + default: present +notes: +- This must be run on a host that has the ActiveDirectory powershell module installed. +seealso: +- module: ansible.windows.win_domain +- module: ansible.windows.win_domain_controller +- module: community.windows.win_domain_computer +- module: ansible.windows.win_domain_membership +- module: community.windows.win_domain_user +- module: ansible.windows.win_group +- module: ansible.windows.win_group_membership +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Ensure the group Cow exists using sAMAccountName + community.windows.win_domain_group: + name: Cow + scope: global + path: OU=groups,DC=ansible,DC=local + +- name: Ensure the group Cow doesn't exist using the Distinguished Name + community.windows.win_domain_group: + name: CN=Cow,OU=groups,DC=ansible,DC=local + state: absent + +- name: Delete group ignoring the protection flag + community.windows.win_domain_group: + name: Cow + state: absent + ignore_protection: yes + +- name: Create group with delete protection enabled and custom attributes + community.windows.win_domain_group: + name: Ansible Users + scope: domainlocal + category: security + attributes: + mail: helpdesk@ansible.com + wWWHomePage: www.ansible.com + ignore_protection: yes + +- name: Change the OU of a group using the SID and ignore the protection flag + community.windows.win_domain_group: + name: S-1-5-21-2171456218-3732823212-122182344-1189 + scope: global + organizational_unit: OU=groups,DC=ansible,DC=local + ignore_protection: yes + +- name: Add managed_by user + community.windows.win_domain_group: + name: Group Name Here + managed_by: Domain Admins + +- name: Add group and specify the AD domain services to use for the create + community.windows.win_domain_group: + name: Test Group + domain_username: user@CORP.ANSIBLE.COM + domain_password: Password01! + domain_server: corp-DC12.corp.ansible.com + scope: domainlocal +''' + +RETURN = r''' +attributes: + description: Custom attributes that were set by the module. This does not + show all the custom attributes rather just the ones that were set by the + module. + returned: group exists and attributes are set on the module invocation + type: dict + sample: + mail: 'helpdesk@ansible.com' + wWWHomePage: 'www.ansible.com' +canonical_name: + description: The canonical name of the group. + returned: group exists + type: str + sample: ansible.local/groups/Cow +category: + description: The Group type value of the group, i.e. Security or Distribution. + returned: group exists + type: str + sample: Security +description: + description: The Description of the group. + returned: group exists + type: str + sample: Group Description +display_name: + description: The Display name of the group. + returned: group exists + type: str + sample: Users who connect through RDP +distinguished_name: + description: The full Distinguished Name of the group. + returned: group exists + type: str + sample: CN=Cow,OU=groups,DC=ansible,DC=local +group_scope: + description: The Group scope value of the group. + returned: group exists + type: str + sample: Universal +guid: + description: The guid of the group. + returned: group exists + type: str + sample: 512a9adb-3fc0-4a26-9df0-e6ea1740cf45 +managed_by: + description: The full Distinguished Name of the AD object that is set on the + managedBy attribute. + returned: group exists + type: str + sample: CN=Domain Admins,CN=Users,DC=ansible,DC=local +name: + description: The name of the group. + returned: group exists + type: str + sample: Cow +protected_from_accidental_deletion: + description: Whether the group is protected from accidental deletion. + returned: group exists + type: bool + sample: true +sid: + description: The Security ID of the group. + returned: group exists + type: str + sample: S-1-5-21-2171456218-3732823212-122182344-1189 +created: + description: Whether a group was created + returned: always + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.ps1 new file mode 100644 index 00000000..6393e3c5 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.ps1 @@ -0,0 +1,136 @@ +#!powershell + +# Copyright: (c) 2019, Marius Rieder <marius.rieder@scs.ch> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +try { + Import-Module ActiveDirectory +} +catch { + Fail-Json -obj @{} -message "win_domain_group_membership requires the ActiveDirectory PS module to be installed" +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +# Module control parameters +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent", "pure" +$domain_username = Get-AnsibleParam -obj $params -name "domain_username" -type "str" +$domain_password = Get-AnsibleParam -obj $params -name "domain_password" -type "str" -failifempty ($null -ne $domain_username) +$domain_server = Get-AnsibleParam -obj $params -name "domain_server" -type "str" + +# Group Membership parameters +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$members = Get-AnsibleParam -obj $params -name "members" -type "list" -failifempty $true + +# Filter ADObjects by ObjectClass +$ad_object_class_filter = "(ObjectClass -eq 'user' -or ObjectClass -eq 'group' -or ObjectClass -eq 'computer' -or ObjectClass -eq 'msDS-ManagedServiceAccount')" + +$extra_args = @{} +if ($null -ne $domain_username) { + $domain_password = ConvertTo-SecureString $domain_password -AsPlainText -Force + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $domain_username, $domain_password + $extra_args.Credential = $credential +} +if ($null -ne $domain_server) { + $extra_args.Server = $domain_server +} + +$ADGroup = Get-ADGroup -Identity $name @extra_args + +$result = @{ + changed = $false + added = [System.Collections.Generic.List`1[String]]@() + removed = [System.Collections.Generic.List`1[String]]@() +} +if ($diff_mode) { + $result.diff = @{} +} + +$filter = "(memberOf=$($ADGroup.DistinguishedName))" + +$members_before = Get-ADObject -LDAPFilter $filter -Properties sAMAccountName, objectSID @extra_args +$pure_members = [System.Collections.Generic.List`1[String]]@() + +foreach ($member in $members) { + $extra_member_args = $extra_args.Clone() + if ($member -match "\\") { + $extra_member_args.Server = $member.Split("\")[0] + $member = $member.Split("\")[1] + } + $group_member = Get-ADObject -Filter "SamAccountName -eq '$member' -and $ad_object_class_filter" -Properties objectSid, sAMAccountName @extra_member_args + if (!$group_member) { + Fail-Json -obj $result "Could not find domain user, group, service account or computer named $member" + } + + if ($state -eq "pure") { + $pure_members.Add($group_member.objectSid) + } + + $user_in_group = $false + foreach ($current_member in $members_before) { + if ($current_member.objectSid -eq $group_member.objectSid) { + $user_in_group = $true + break + } + } + + if ($state -in @("present", "pure") -and !$user_in_group) { + Add-ADPrincipalGroupMembership -Identity $group_member -MemberOf $ADGroup -WhatIf:$check_mode @extra_member_args + $result.added.Add($group_member.SamAccountName) + $result.changed = $true + } + elseif ($state -eq "absent" -and $user_in_group) { + Remove-ADPrincipalGroupMembership -Identity $group_member -MemberOf $ADGroup -WhatIf:$check_mode -Confirm:$False @extra_member_args + $result.removed.Add($group_member.SamAccountName) + $result.changed = $true + } +} + +if ($state -eq "pure") { + # Perform removals for existing group members not defined in $members + $current_members = Get-ADObject -LDAPFilter $filter -Properties sAMAccountName, objectSID @extra_args + + foreach ($current_member in $current_members) { + $user_to_remove = $true + foreach ($pure_member in $pure_members) { + if ($pure_member -eq $current_member.objectSid) { + $user_to_remove = $false + break + } + } + + if ($user_to_remove) { + Remove-ADPrincipalGroupMembership -Identity $current_member -MemberOf $ADGroup -WhatIf:$check_mode -Confirm:$False @extra_member_args + $result.removed.Add($current_member.SamAccountName) + $result.changed = $true + } + } +} + +$final_members = Get-ADObject -LDAPFilter $filter -Properties sAMAccountName, objectSID @extra_args + +if ($final_members) { + $result.members = [Array]$final_members.SamAccountName +} +else { + $result.members = @() +} + +if ($diff_mode -and $result.changed) { + $result.diff.before = $members_before.SamAccountName | Out-String + if (!$check_mode) { + $result.diff.after = [Array]$final_members.SamAccountName | Out-String + } + else { + $after = [System.Collections.Generic.List`1[String]]$result.members + $result.removed | ForEach-Object { $after.Remove($_) > $null } + $after.AddRange($result.added) + $result.diff.after = $after | Out-String + } +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.py b/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.py new file mode 100644 index 00000000..5e10ac3b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_group_membership.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Andrew Saraceni <andrew.saraceni@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_group_membership +short_description: Manage Windows domain group membership +description: + - Allows the addition and removal of domain users + and domain groups from/to a domain group. +options: + name: + description: + - Name of the domain group to manage membership on. + type: str + required: yes + members: + description: + - A list of members to ensure are present/absent from the group. + - The given names must be a SamAccountName of a user, group, service account, or computer. + - For computers, you must add "$" after the name; for example, to add "Mycomputer" to a group, use "Mycomputer$" as the member. + - If the member object is part of another domain in a multi-domain forest, you must add the domain and "\" in front of the name. + type: list + elements: str + required: yes + state: + description: + - Desired state of the members in the group. + - When C(state) is C(pure), only the members specified will exist, + and all other existing members not specified are removed. + type: str + choices: [ absent, present, pure ] + default: present + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user Ansible used to log in with will be + used instead when using CredSSP or Kerberos with credential delegation. + type: str + domain_password: + description: + - The password for I(username). + type: str + domain_server: + description: + - Specifies the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the domain of the computer + running PowerShell. + type: str +notes: +- This must be run on a host that has the ActiveDirectory powershell module installed. +seealso: +- module: community.windows.win_domain_user +- module: community.windows.win_domain_group +author: + - Marius Rieder (@jiuka) +''' + +EXAMPLES = r''' +- name: Add a domain user/group to a domain group + community.windows.win_domain_group_membership: + name: Foo + members: + - Bar + state: present + +- name: Remove a domain user/group from a domain group + community.windows.win_domain_group_membership: + name: Foo + members: + - Bar + state: absent + +- name: Ensure only a domain user/group exists in a domain group + community.windows.win_domain_group_membership: + name: Foo + members: + - Bar + state: pure + +- name: Add a computer to a domain group + community.windows.win_domain_group_membership: + name: Foo + members: + - DESKTOP$ + state: present + +- name: Add a domain user/group from another Domain in the multi-domain forest to a domain group + community.windows.win_domain_group_membership: + domain_server: DomainAAA.cloud + name: GroupinDomainAAA + members: + - DomainBBB.cloud\UserInDomainBBB + state: Present + +''' + +RETURN = r''' +name: + description: The name of the target domain group. + returned: always + type: str + sample: Domain-Admins +added: + description: A list of members added when C(state) is C(present) or + C(pure); this is empty if no members are added. + returned: success and C(state) is C(present) or C(pure) + type: list + sample: ["UserName", "GroupName"] +removed: + description: A list of members removed when C(state) is C(absent) or + C(pure); this is empty if no members are removed. + returned: success and C(state) is C(absent) or C(pure) + type: list + sample: ["UserName", "GroupName"] +members: + description: A list of all domain group members at completion; this is empty + if the group contains no members. + returned: success + type: list + sample: ["UserName", "GroupName"] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_object_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_object_info.ps1 new file mode 100644 index 00000000..12347946 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_object_info.ps1 @@ -0,0 +1,284 @@ +#!powershell + +# Copyright: (c) 2020, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + domain_password = @{ type = 'str'; no_log = $true } + domain_server = @{ type = 'str' } + domain_username = @{ type = 'str' } + filter = @{ type = 'str' } + identity = @{ type = 'str' } + include_deleted = @{ type = 'bool'; default = $false } + ldap_filter = @{ type = 'str' } + properties = @{ type = 'list'; elements = 'str' } + search_base = @{ type = 'str' } + search_scope = @{ type = 'str'; choices = @('base', 'one_level', 'subtree') } + } + supports_check_mode = $true + mutually_exclusive = @( + @('filter', 'identity', 'ldap_filter'), + @('identity', 'search_base'), + @('identity', 'search_scope') + ) + required_one_of = @( + , @('filter', 'identity', 'ldap_filter') + ) + required_together = @(, @('domain_username', 'domain_password')) +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.objects = @() # Always ensure this is returned even in a failure. + +$domainServer = $module.Params.domain_server +$domainPassword = $module.Params.domain_password +$domainUsername = $module.Params.domain_username +$filter = $module.Params.filter +$identity = $module.Params.identity +$includeDeleted = $module.Params.include_deleted +$ldapFilter = $module.Params.ldap_filter +$properties = $module.Params.properties +$searchBase = $module.Params.search_base +$searchScope = $module.Params.search_scope + +$credential = $null +if ($domainUsername) { + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $domainUsername, + (ConvertTo-SecureString -AsPlainText -Force -String $domainPassword) + ) +} + +Add-CSharpType -References @' +using System; + +namespace Ansible.WinDomainObjectInfo +{ + [Flags] + public enum UserAccountControl : int + { + ADS_UF_SCRIPT = 0x00000001, + ADS_UF_ACCOUNTDISABLE = 0x00000002, + ADS_UF_HOMEDIR_REQUIRED = 0x00000008, + ADS_UF_LOCKOUT = 0x00000010, + ADS_UF_PASSWD_NOTREQD = 0x00000020, + ADS_UF_PASSWD_CANT_CHANGE = 0x00000040, + ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED = 0x00000080, + ADS_UF_TEMP_DUPLICATE_ACCOUNT = 0x00000100, + ADS_UF_NORMAL_ACCOUNT = 0x00000200, + ADS_UF_INTERDOMAIN_TRUST_ACCOUNT = 0x00000800, + ADS_UF_WORKSTATION_TRUST_ACCOUNT = 0x00001000, + ADS_UF_SERVER_TRUST_ACCOUNT = 0x00002000, + ADS_UF_DONT_EXPIRE_PASSWD = 0x00010000, + ADS_UF_MNS_LOGON_ACCOUNT = 0x00020000, + ADS_UF_SMARTCARD_REQUIRED = 0x00040000, + ADS_UF_TRUSTED_FOR_DELEGATION = 0x00080000, + ADS_UF_NOT_DELEGATED = 0x00100000, + ADS_UF_USE_DES_KEY_ONLY = 0x00200000, + ADS_UF_DONT_REQUIRE_PREAUTH = 0x00400000, + ADS_UF_PASSWORD_EXPIRED = 0x00800000, + ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x01000000, + } + + public enum sAMAccountType : int + { + SAM_DOMAIN_OBJECT = 0x00000000, + SAM_GROUP_OBJECT = 0x10000000, + SAM_NON_SECURITY_GROUP_OBJECT = 0x10000001, + SAM_ALIAS_OBJECT = 0x20000000, + SAM_NON_SECURITY_ALIAS_OBJECT = 0x20000001, + SAM_USER_OBJECT = 0x30000000, + SAM_NORMAL_USER_ACCOUNT = 0x30000000, + SAM_MACHINE_ACCOUNT = 0x30000001, + SAM_TRUST_ACCOUNT = 0x30000002, + SAM_APP_BASIC_GROUP = 0x40000000, + SAM_APP_QUERY_GROUP = 0x40000001, + SAM_ACCOUNT_TYPE_MAX = 0x7fffffff, + } +} +'@ + +Function ConvertTo-OutputValue { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + [AllowNull()] + [Object] + $InputObject + ) + + if ($InputObject -is [System.Security.Principal.SecurityIdentifier]) { + # Syntax: SID - Only serialize the SID as a string and not the other metadata properties. + $sidInfo = @{ + Sid = $InputObject.Value + } + + # Try and map the SID to the account name, this may fail if the SID is invalid or not mappable. + try { + $sidInfo.Name = $InputObject.Translate([System.Security.Principal.NTAccount]).Value + } + catch [System.Security.Principal.IdentityNotMappedException] { + $sidInfo.Name = $null + } + + $sidInfo + } + elseif ($InputObject -is [Byte[]]) { + # Syntax: Octet String - By default will serialize as a list of decimal values per byte, instead return a + # Base64 string as Ansible can easily parse that. + [System.Convert]::ToBase64String($InputObject) + } + elseif ($InputObject -is [DateTime]) { + # Syntax: UTC Coded Time - .NET DateTimes serialized as in the form "Date(FILETIME)" which isn't easily + # parsable by Ansible, instead return as an ISO 8601 string in the UTC timezone. + [TimeZoneInfo]::ConvertTimeToUtc($InputObject).ToString("o") + } + elseif ($InputObject -is [System.Security.AccessControl.ObjectSecurity]) { + # Complex object which isn't easily serializable. Instead we should just return the SDDL string. If a user + # needs to parse this then they really need to reprocess the SDDL string and process their results on another + # win_shell task. + $InputObject.GetSecurityDescriptorSddlForm(([System.Security.AccessControl.AccessControlSections]::All)) + } + else { + # Syntax: (All Others) - The default serialization handling of other syntaxes are fine, don't do anything. + $InputObject + } +} + +<# +Calling Get-ADObject that returns multiple objects with -Properties * will only return the properties that were set on +the first found object. To counter this problem we will first call Get-ADObject to list all the objects that match the +filter specified then get the properties on each object. +#> + +$commonParams = @{ + IncludeDeletedObjects = $includeDeleted +} + +if ($credential) { + $commonParams.Credential = $credential +} + +if ($domainServer) { + $commonParams.Server = $domainServer +} + +# First get the IDs for all the AD objects that match the filter specified. +$getParams = @{ + Properties = @('DistinguishedName', 'ObjectGUID') +} + +if ($filter) { + $getParams.Filter = $filter +} +elseif ($identity) { + $getParams.Identity = $identity +} +elseif ($ldapFilter) { + $getParams.LDAPFilter = $ldapFilter +} + +# Explicit check on $null as an empty string is different from not being set. +if ($null -ne $searchBase) { + $getParams.SearchBase = $searchbase +} + +if ($searchScope) { + $getParams.SearchScope = switch ($searchScope) { + base { 'Base' } + one_level { 'OneLevel' } + subtree { 'Subtree' } + } +} + +try { + # We run this in a custom PowerShell pipeline so that users of this module can't use any of the variables defined + # above in their filter. While the cmdlet won't execute sub expressions we don't want anyone implicitly relying on + # a defined variable in this module in case we ever change the name or remove it. + $ps = [PowerShell]::Create() + $null = $ps.AddCommand('Get-ADObject').AddParameters($commonParams).AddParameters($getParams) + $null = $ps.AddCommand('Select-Object').AddParameter('Property', @('DistinguishedName', 'ObjectGUID')) + + $foundGuids = @($ps.Invoke()) +} +catch { + # Because we ran in a pipeline we can't catch ADIdentityNotFoundException. Instead just get the base exception and + # do the error checking on that. + if ($_.Exception.GetBaseException() -is [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]) { + $foundGuids = @() + } + else { + # The exception is from the .Invoke() call, compare on the InnerException which was what was actually raised by + # the pipeline. + $innerException = $_.Exception.InnerException.InnerException + if ($innerException -is [Microsoft.ActiveDirectory.Management.ADServerDownException]) { + # Point users in the direction of the double hop problem as that is what is typically the cause of this. + $msg = "Failed to contact the AD server, this could be caused by the double hop problem over WinRM. " + $msg += "Try using the module with auth as Kerberos with credential delegation or CredSSP, become, or " + $msg += "defining the domain_username and domain_password module parameters." + $module.FailJson($msg, $innerException) + } + else { + throw $innerException + } + } +} + +$getParams = @{} +if ($properties) { + $getParams.Properties = $properties +} +$module.Result.objects = @(foreach ($adId in $foundGuids) { + try { + $adObject = Get-ADObject @commonParams @getParams -Identity $adId.ObjectGUID + } + catch { + $msg = "Failed to retrieve properties for AD Object '$($adId.DistinguishedName)': $($_.Exception.Message)" + $module.Warn($msg) + continue + } + + $propertyNames = $adObject.PropertyNames + $propertyNames += ($properties | Where-Object { $_ -ne '*' }) + + # Now process each property to an easy to represent string + $filteredObject = [Ordered]@{} + foreach ($name in ($propertyNames | Sort-Object)) { + # In the case of explicit properties that were asked for but weren't set, Get-ADObject won't actually return + # the property so this is a defensive check against that scenario. + if (-not $adObject.PSObject.Properties.Name.Contains($name)) { + $filteredObject.$name = $null + continue + } + + $value = $adObject.$name + if ($value -is [Microsoft.ActiveDirectory.Management.ADPropertyValueCollection]) { + $value = foreach ($v in $value) { + ConvertTo-OutputValue -InputObject $v + } + } + else { + $value = ConvertTo-OutputValue -InputObject $value + } + $filteredObject.$name = $value + + # For these 2 properties, add an _AnsibleFlags attribute which contains the enum strings that are set. + if ($name -eq 'sAMAccountType') { + $enumValue = [Ansible.WinDomainObjectInfo.sAMAccountType]$value + $filteredObject.'sAMAccountType_AnsibleFlags' = $enumValue.ToString() -split ', ' + } + elseif ($name -eq 'userAccountControl') { + $enumValue = [Ansible.WinDomainObjectInfo.UserAccountControl]$value + $filteredObject.'userAccountControl_AnsibleFlags' = $enumValue.ToString() -split ', ' + } + } + + $filteredObject + }) + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_object_info.py b/ansible_collections/community/windows/plugins/modules/win_domain_object_info.py new file mode 100644 index 00000000..200df533 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_object_info.py @@ -0,0 +1,157 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_object_info +short_description: Gather information an Active Directory object +description: +- Gather information about multiple Active Directory object(s). +options: + domain_password: + description: + - The password for C(domain_username). + type: str + domain_server: + description: + - Specified the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the default domain of the computer running PowerShell. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user that is used for authentication will be the connection user. + - Ansible will be unable to use the connection user unless auth is Kerberos with credential delegation or CredSSP, + or become is used on the task. + type: str + filter: + description: + - Specifies a query string using the PowerShell Expression Language syntax. + - This follows the same rules and formatting as the C(-Filter) parameter for the PowerShell AD cmdlets exception + there is no variable substitutions. + - This is mutually exclusive with I(identity) and I(ldap_filter). + type: str + identity: + description: + - Specifies a single Active Directory object by its distinguished name or its object GUID. + - This is mutually exclusive with I(filter) and I(ldap_filter). + - This cannot be used with either the I(search_base) or I(search_scope) options. + type: str + include_deleted: + description: + - Also search for deleted Active Directory objects. + default: no + type: bool + ldap_filter: + description: + - Like I(filter) but this is a tradiitional LDAP query string to filter the objects to return. + - This is mutually exclusive with I(filter) and I(identity). + type: str + properties: + description: + - A list of properties to return. + - If a property is C(*), all properties that have a set value on the AD object will be returned. + - If a property is valid on the object but not set, it is only returned if defined explicitly in this option list. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + - Specifying multiple properties can have a performance impact, it is best to only return what is needed. + - If an invalid property is specified then the module will display a warning for each object it is invalid on. + type: list + elements: str + search_base: + description: + - Specify the Active Directory path to search for objects in. + - This cannot be set with I(identity). + - By default the search base is the default naming context of the target AD instance which is the DN returned by + "(Get-ADRootDSE).defaultNamingContext". + type: str + search_scope: + description: + - Specify the scope of when searching for an object in the C(search_base). + - C(base) will limit the search to the base object so the maximum number of objects returned is always one. This + will not search any objects inside a container.. + - C(one_level) will search the current path and any immediate objects in that path. + - C(subtree) will search the current path and all objects of that path recursively. + - This cannot be set with I(identity). + choices: + - base + - one_level + - subtree + type: str +notes: +- The C(sAMAccountType_AnsibleFlags) and C(userAccountControl_AnsibleFlags) return property is something set by the + module itself as an easy way to view what those flags represent. These properties cannot be used as part of the + I(filter) or I(ldap_filter) and are automatically added if those properties were requested. +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Get all properties for the specified account using its DistinguishedName + community.windows.win_domain_object_info: + identity: CN=Username,CN=Users,DC=domain,DC=com + properties: '*' + +- name: Get the SID for all user accounts as a filter + community.windows.win_domain_object_info: + filter: ObjectClass -eq 'user' -and objectCategory -eq 'Person' + properties: + - objectSid + +- name: Get the SID for all user accounts as a LDAP filter + community.windows.win_domain_object_info: + ldap_filter: (&(objectClass=user)(objectCategory=Person)) + properties: + - objectSid + +- name: Search all computer accounts in a specific path that were added after February 1st + community.windows.win_domain_object_info: + filter: objectClass -eq 'computer' -and whenCreated -gt '20200201000000.0Z' + properties: '*' + search_scope: one_level + search_base: CN=Computers,DC=domain,DC=com +''' + +RETURN = r''' +objects: + description: + - A list of dictionaries that are the Active Directory objects found and the properties requested. + - The dict's keys are the property name and the value is the value for the property. + - All date properties are return in the ISO 8601 format in the UTC timezone. + - All SID properties are returned as a dict with the keys C(Sid) as the SID string and C(Name) as the translated SID + account name. + - All byte properties are returned as a base64 string. + - All security descriptor properties are returned as the SDDL string of that descriptor. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + returned: always + type: list + elements: dict + sample: | + [{ + "accountExpires": 0, + "adminCount": 1, + "CanonicalName": "domain.com/Users/Administrator", + "CN": "Administrator", + "Created": "2020-01-13T09:03:22.0000000Z", + "Description": "Built-in account for administering computer/domain", + "DisplayName": null, + "DistinguishedName": "CN=Administrator,CN=Users,DC=domain,DC=com", + "memberOf": [ + "CN=Group Policy Creator Owners,CN=Users,DC=domain,DC=com", + "CN=Domain Admins",CN=Users,DC=domain,DC=com" + ], + "Name": "Administrator", + "nTSecurityDescriptor": "O:DAG:DAD:PAI(A;;LCRPLORC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)(A;;CCDCLCSWRPWPLOCRSDRCWDWO;;;BA)", + "ObjectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=domain,DC=com", + "ObjectClass": "user", + "ObjectGUID": "c8c6569e-4688-4f3c-8462-afc4ff60817b", + "objectSid": { + "Sid": "S-1-5-21-2959096244-3298113601-420842770-500", + "Name": "DOMAIN\Administrator" + }, + "sAMAccountName": "Administrator", + }] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_ou.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_ou.ps1 new file mode 100644 index 00000000..ee6cdc45 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_ou.ps1 @@ -0,0 +1,253 @@ +#!powershell + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module ActiveDirectory + +$spec = @{ + options = @{ + state = @{ type = "str"; choices = @("absent", "present"); default = "present" } + name = @{ type = "str"; required = $true } + protected = @{ type = "bool"; default = $false } + path = @{ type = "str"; required = $false } + filter = @{type = "str"; default = '*' } + recursive = @{ type = "bool"; default = $false } + domain_username = @{ type = "str"; } + domain_password = @{ type = "str"; no_log = $true } + domain_server = @{ type = "str" } + properties = @{ type = "dict" } + } + required_together = @( + , @('domain_password', 'domain_username') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$extra_args = @{} +$onboard_extra_args = @{} +if ($null -ne $module.Params.domain_username) { + $domain_password = ConvertTo-SecureString $module.Params.domain_password -AsPlainText -Force + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $module.Params.domain_username, $domain_password + $extra_args.Credential = $credential + $onboard_extra_args.Credential = $credential +} +if ($null -ne $module.Params.domain_server) { + $extra_args.Server = $module.Params.domain_server + $onboard_extra_args.Server = $module.Params.domain_server +} +if ($module.Params.properties.count -ne 0) { + $Properties = New-Object Collections.Generic.List[string] + $module.Params.properties.Keys | Foreach-Object { + $Properties.Add($_) + } + $extra_args.Properties = $Properties +} +else { + $extra_args.Properties = '*' + $Properties = '*' +} + +$extra_args.Filter = $module.Params.filter +$check_mode = $module.CheckMode +$name = $module.Params.name +$protected = $module.Params.protected +$path = $module.Params.path +$state = $module.Params.state +$recursive = $module.Params.recursive + +# setup Dynamic Params +$params = @{} +if ($module.Params.properties.count -ne 0) { + $module.Params.properties.Keys | ForEach-Object { + $params.Add($_, $module.Params.properties.Item($_)) + } +} + +Function Get-SimulatedOu { + Param($Object) + $ou = @{ + Name = $Object.name + DistinguishedName = "OU=$($Object.name),$($Object.path)" + ProtectedFromAccidentalDeletion = $Object.protected + Properties = New-Object Collections.Generic.List[string] + } + $ou.Properties.Add("Name") + $ou.Properties.Add("DistinguishedName") + $ou.Properties.Add("ProtectedFromAccidentalDeletion") + if ($Object.Params.properties.Count -ne 0) { + $Object.Params.properties.Keys | ForEach-Object { + $property = $_ + $module.Result.simulate_property = $property + $ou.Add($property, $Object.Params.properties.Item($property)) + $ou.Properties.Add($property) + } + } + # convert to psobject & return + [PSCustomObject]$ou +} + +Function Get-OuObject { + Param([PSObject]$Object) + $obj = $Object | Select-Object -Property * -ExcludeProperty nTSecurityDescriptor | ConvertTo-Json -Depth 1 | ConvertFrom-Json + return $obj +} + +# attempt import of module +Try { Import-Module ActiveDirectory } +Catch { $module.FailJson("The ActiveDirectory module failed to load properly: $($_.Exception.Message)", $_) } +Try { + $all_ous = Get-ADOrganizationalUnit @extra_args +} +Catch { $module.FailJson("Get-ADOrganizationalUnit failed: $($_.Exception.Message)", $_) } + +# set path if not defined to base domain +if ($null -eq $path) { + if ($($all_ous | Measure-Object | Select-Object -ExpandProperty Count) -eq 1) { + $matched = $all_ous.DistinguishedName -match "DC=.+" + } + elseif ($($all_ous | Measure-Object | Select-Object -ExpandProperty Count) -gt 1) { + $matched = $all_ous[0].DistinguishedName -match "DC=.+" + } + else { + $module.FailJson("Path was null and unable to determine default domain $($_.Exception.Message)") + } + if ($matched) { + $path = $matches.Values[0] + } + else { + $module.FailJson("Unable to find default domain $($_.Exception.Message)") + } +} +$module.Result.path = $path + +# determine if requested OU exist +$current_ou = $false +Try { + $current_ou = $all_ous | Where-Object { + $_.DistinguishedName -eq "OU=$name,$path" } + $module.Diff.before = Get-OuObject -Object $current_ou + $module.Result.ou = Get-OuObject $module.Diff.before +} +Catch { + $module.Diff.before = "" + $current_ou = $false +} + +# determine if ou needs created +if (($state -eq "present") -and (-not $current_ou)) { + $create_ou = $true +} +else { + $create_ou = $false +} + +# determine if ou needs change +$update_ou = $false +if (($state -eq "present") -and ($create_ou -eq $false)) { + if ($module.Params.properties.Count -ne 0) { + $changed_properties = New-Object Collections.Generic.List[hashtable] + $module.Params.properties.Keys | ForEach-Object { + $property = $_ + $current_value = $current_ou.Item($property) + $requested_value = $module.Params.properties.Item($property) + if (-not ($current_value -eq $requested_value) ) { + $changed_properties.Add( + @{ + "Actual_$property" = $current_value + "Requested_$property" = $requested_value + } + ) + } + } + if ($changed_properties.Count -ge 1) { + $update_ou = $true + } + } +} + +if ($state -eq "present") { + # ou does not exist, create object + if ($create_ou) { + $params.Name = $name + $params.Path = $path + Try { + New-ADOrganizationalUnit @params @onboard_extra_args -ProtectedFromAccidentalDeletion $protected -WhatIf:$check_mode + } + Catch { + $module.FailJson("Failed to create organizational unit: $($_.Exception.Message)", $_) + } + $module.Result.changed = $true + if ($check_mode) { + $module.Diff.after = Get-SimulatedOu -Object $module + } + else { + $new_ou = Get-ADOrganizationalUnit @extra_args | Where-Object { + $_.DistinguishedName -eq "OU=$name,$path" + } + $module.Diff.after = Get-OuObject -Object $new_ou + } + } + # ou exists, update object if needed + if ($update_ou) { + Try { + Set-ADOrganizationalUnit -Identity "OU=$name,$path" @params @onboard_extra_args -WhatIf:$check_mode + $module.Result.changed = $true + } + Catch { + $module.FailJson("Failed to update organizational unit: $($_.Exception.Message)", $_) + } + if ($check_mode) { + $module.Diff.after = Get-SimulatedOu -Object $module + } + else { + $new_ou = Get-ADOrganizationalUnit @extra_args | Where-Object { + $_.DistinguishedName -eq "OU=$name,$path" + } + $module.Diff.after = Get-OuObject -Object $new_ou + } + } +} + +if ($state -eq "absent") { + # ou exists, delete object + if ($current_ou) { + Try { + # override protected from accidental deletion + Set-ADOrganizationalUnit -Identity "OU=$name,$path" -ProtectedFromAccidentalDeletion $false @onboard_extra_args -Confirm:$False -WhatIf:$check_mode + $module.Result.changed = $true + } + Catch { + $module.FailJson("Failed to remove ProtectedFromAccidentalDeletion Lock: $($_.Exception.Message)", $_) + } + # check recursive deletion + if ($recursive) { + try { + Remove-ADOrganizationalUnit -Identity "OU=$name,$path" -Confirm:$False -WhatIf:$check_mode -Recursive @onboard_extra_args + $module.Result.changed = $true + $module.Diff.after = "" + $module.Result.ou = "" + } + catch { + $module.FailJson("Failed to recursively Remove-ADOrganizationalUnit $($_.Exception.Message)", $_) + } + } + else { + try { + Remove-ADOrganizationalUnit -Identity "OU=$name,$path" -Confirm:$False -WhatIf:$check_mode @onboard_extra_args + $module.Result.changed = $true + $module.Diff.after = "" + $module.Result.ou = "" + } + Catch { + $module.FailJson("Failed to Remove-ADOrganizationalUnit: $($_.Exception.Message)", $_) + } + } + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_ou.py b/ansible_collections/community/windows/plugins/modules/win_domain_ou.py new file mode 100644 index 00000000..e144b837 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_ou.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: GPL-3.0-only +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_ou +short_description: Manage Active Directory Organizational Units +author: ['Joe Zollo (@joezollo)', 'Larry Lane (@gamethis)'] +version_added: 1.8.0 +requirements: + - This module requires Windows Server 2012 or Newer + - Powershell ActiveDirectory Module +description: + - Manage Active Directory Organizational Units + - Adds, Removes and Modifies Active Directory Organizational Units + - Task should be delegated to a Windows Active Directory Domain Controller +options: + name: + description: + - The name of the Organizational Unit + type: str + required: true + protected: + description: + - Indicates whether to prevent the object from being deleted. When this + I(protected=true), you cannot delete the corresponding object without + changing the value of the property. + type: bool + default: false + path: + description: + - Specifies the X.500 path of the OU or container where the new object is + created. + - defaults to adding ou at base of domain connected to. + type: str + required: false + state: + description: + - Specifies the desired state of the OU. + - When I(state=present) the module will attempt to create the specified + OU if it does not already exist. + - When I(state=absent), the module will remove the specified OU. + - When I(state=absent) and I(recursive=true), the module will remove all + the OU and all child OU's. + type: str + default: present + choices: [ present, absent ] + recursive: + description: + - Removes the OU and any child items it contains. + - You must specify this parameter to remove an OU that is not empty. + type: bool + default: false + domain_server: + description: + - Specifies the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the domain of the computer + running PowerShell. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user Ansible used to log in with will be + used instead when using CredSSP or Kerberos with credential delegation. + type: str + domain_password: + type: str + description: + - The password for the domain you are accessing + filter: + type: str + description: filter for lookup of ou. + default: '*' + properties: + type: dict + description: + - Free form dict of properties for the organizational unit. Follows LDAP property names, like C(StreetAddress) or C(PostalCode). +''' + +EXAMPLES = r''' +--- +- name: Ensure OU is present & protected + community.windows.win_domain_ou: + name: AnsibleFest + state: present + +- name: Ensure OU is present & protected + community.windows.win_domain_ou: + name: EUC Users + path: "DC=euc,DC=vmware,DC=lan" + state: present + protected: true + delegate_to: win-ad1.euc.vmware.lab + +- name: Ensure OU is absent + community.windows.win_domain_ou: + name: EUC Users + path: "DC=euc,DC=vmware,DC=lan" + state: absent + delegate_to: win-ad1.euc.vmware.lab + +- name: Ensure OU is present with specific properties + community.windows.win_domain_ou: + name: WS1Users + path: "CN=EUC Users,DC=euc,DC=vmware,DC=lan" + protected: true + properties: + city: Sandy Springs + state: Georgia + StreetAddress: 1155 Perimeter Center West + country: US + description: EUC Business Unit + PostalCode: 30189 + delegate_to: win-ad1.euc.vmware.lab + +- name: Ensure OU updated with new properties + community.windows.win_domain_ou: + name: WS1Users + path: DC=euc,DC=vmware,DC=lan + protected: false + properties: + city: Atlanta + state: Georgia + managedBy: jzollo@vmware.com + delegate_to: win-ad1.euc.vmware.lab +''' + +RETURN = r''' +path: + description: + - Base ou path used by module either when provided I(path=DC=Ansible,DC=Test) or derived by module. + returned: always + type: str + sample: + path: "DC=ansible,DC=test" +ou: + description: + - New/Updated organizational unit parameters + returned: When I(state=present) + type: dict + sample: + AddedProperties: [] + City: "Sandy Springs" + Country: null + DistinguishedName: "OU=VMW Atlanta,DC=ansible,DC=test" + LinkedGroupPolicyObjects: [] + ManagedBy: null + ModifiedProperties: [] + Name: "VMW Atlanta" + ObjectClass: "organizationalUnit" + ObjectGUID: "3e987e30-93ad-4229-8cd0-cff6a91275e4" + PostalCode: null + PropertyCount: 11 + PropertyNames: + City + Country + DistinguishedName + LinkedGroupPolicyObjects + ManagedBy + Name + ObjectClass + ObjectGUID + PostalCode + State + StreetAddress + RemovedProperties: [] + State: "Georgia" + StreetAddress: "1155 Perimeter Center West" +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_user.ps1 b/ansible_collections/community/windows/plugins/modules/win_domain_user.ps1 new file mode 100644 index 00000000..e87c606c --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_user.ps1 @@ -0,0 +1,564 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.AccessToken +#AnsibleRequires -CSharpUtil Ansible.Basic + +Function Test-Credential { + param( + [String]$Username, + [String]$Password, + [String]$Domain = $null + ) + if (($Username.ToCharArray()) -contains [char]'@') { + # UserPrincipalName + $Domain = $null # force $Domain to be null, to prevent undefined behaviour, as a domain name is already included in the username + } + elseif (($Username.ToCharArray()) -contains [char]'\') { + # Pre Win2k Account Name + $Domain = ($Username -split '\\')[0] + $Username = ($Username -split '\\', 2)[-1] + } # If no domain provided, so maybe local user, or domain specified separately. + + try { + $handle = [Ansible.AccessToken.TokenUtil]::LogonUser($Username, $Domain, $Password, "Network", "Default") + $handle.Dispose() + return $true + } + catch [Ansible.AccessToken.Win32Exception] { + # following errors indicate the creds are correct but the user was + # unable to log on for other reasons, which we don't care about + $success_codes = @( + 0x0000052F, # ERROR_ACCOUNT_RESTRICTION + 0x00000530, # ERROR_INVALID_LOGON_HOURS + 0x00000531, # ERROR_INVALID_WORKSTATION + 0x00000569 # ERROR_LOGON_TYPE_GRANTED + ) + $failed_codes = @( + 0x0000052E, # ERROR_LOGON_FAILURE + 0x00000532, # ERROR_PASSWORD_EXPIRED + 0x00000773, # ERROR_PASSWORD_MUST_CHANGE + 0x00000533 # ERROR_ACCOUNT_DISABLED + ) + + if ($_.Exception.NativeErrorCode -in $failed_codes) { + return $false + } + elseif ($_.Exception.NativeErrorCode -in $success_codes) { + return $true + } + else { + # an unknown failure, reraise exception + throw $_ + } + } +} + +$spec = @{ + options = @{ + name = @{ type = 'str'; required = $true } + state = @{ + type = "str" + choices = @('present', 'absent', 'query') + default = "present" + } + domain_username = @{ type = 'str' } + domain_password = @{ type = 'str'; no_log = $true } + domain_server = @{ type = 'str' } + groups_action = @{ + type = 'str' + choices = @('add', 'remove', 'replace') + default = 'replace' + } + spn_action = @{ + type = 'str' + choices = @('add', 'remove', 'replace') + default = 'replace' + } + spn = @{ + type = 'list' + elements = 'str' + aliases = @('spns') + } + description = @{ type = 'str' } + password = @{ type = 'str'; no_log = $true } + password_expired = @{ type = 'bool' } + password_never_expires = @{ type = 'bool' } + user_cannot_change_password = @{ type = 'bool' } + account_locked = @{ type = 'bool' } + groups = @{ type = 'list'; elements = 'str' } + groups_missing_behaviour = @{ type = 'str'; choices = "fail", "ignore", "warn"; default = "fail" } + enabled = @{ type = 'bool'; default = $true } + path = @{ type = 'str' } + upn = @{ type = 'str' } + sam_account_name = @{ type = 'str' } + identity = @{ type = 'str' } + firstname = @{ type = 'str' } + surname = @{ type = 'str'; aliases = @('lastname') } + display_name = @{ type = 'str' } + company = @{ type = 'str' } + email = @{ type = 'str' } + street = @{ type = 'str' } + city = @{ type = 'str' } + state_province = @{ type = 'str' } + postal_code = @{ type = 'str' } + country = @{ type = 'str' } + attributes = @{ type = 'dict' } + delegates = @{ + type = 'list' + elements = 'str' + aliases = @('principals_allowed_to_delegate') + } + update_password = @{ + type = 'str' + choices = @('always', 'on_create', 'when_changed') + default = 'always' + } + } + required_together = @( + , @("domain_username", "domain_password") + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$check_mode = $module.CheckMode + +$module.Result.created = $false +$module.Result.password_updated = $false + +try { + Import-Module ActiveDirectory +} +catch { + $msg = "Failed to import ActiveDirectory PowerShell module." + $module.FailJson($msg, $_) +} + +# Module control parameters +$state = $module.Params.state +$update_password = $module.Params.update_password +$groups_action = $module.Params.groups_action +$domain_username = $module.Params.domain_username +$domain_password = $module.Params.domain_password +$domain_server = $module.Params.domain_server + +# User account parameters +$name = $module.Params.name +$description = $module.Params.description +$password = $module.Params.password +$password_expired = $module.Params.password_expired +$password_never_expires = $module.Params.password_never_expires +$user_cannot_change_password = $module.Params.user_cannot_change_password +$account_locked = $module.Params.account_locked +$groups = $module.Params.groups +$groups_missing_behaviour = $module.Params.groups_missing_behaviour +$enabled = $module.Params.enabled +$path = $module.Params.path +$upn = $module.Params.upn +$spn = $module.Params.spn +$spn_action = $module.Params.spn_action +$sam_account_name = $module.Params.sam_account_name +$delegates = $module.Params.delegates +$identity = $module.Params.identity + +if ($null -eq $identity) { + $identity = $name +} + +# User informational parameters +$user_info = @{ + GivenName = $module.Params.firstname + Surname = $module.Params.surname + DisplayName = $module.Params.display_name + Company = $module.Params.company + EmailAddress = $module.Params.email + StreetAddress = $module.Params.street + City = $module.Params.city + State = $module.Params.state_province + PostalCode = $module.Params.postal_code + Country = $module.Params.country +} + +# Additional attributes +$attributes = $module.Params.attributes + +# Parameter validation +If ($null -ne $account_locked -and $account_locked) { + $module.FailJson("account_locked must be set to 'no' if provided") +} + +If (($null -ne $password_expired) -and ($null -ne $password_never_expires)) { + $module.FailJson("password_expired and password_never_expires are mutually exclusive but have both been set") +} + +$extra_args = @{} +if ($null -ne $domain_username) { + $domain_password = ConvertTo-SecureString $domain_password -AsPlainText -Force + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $domain_username, $domain_password + $extra_args.Credential = $credential +} + +if ($null -ne $domain_server) { + $extra_args.Server = $domain_server +} + +Function Get-PrincipalGroup { + Param ($identity, $args_extra) + try { + $groups = Get-ADPrincipalGroupMembership ` + -Identity $identity @args_extra ` + -ErrorAction Stop + } + catch { + $module.Warn("Failed to enumerate user groups but continuing on: $($_.Exception.Message)") + return @() + } + + $result_groups = foreach ($group in $groups) { + $group.DistinguishedName + } + return $result_groups +} + +try { + $user_obj = Get-ADUser ` + -Identity $identity ` + -Properties ('*', 'msDS-PrincipalName') @extra_args + $user_guid = $user_obj.ObjectGUID +} +catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + $user_obj = $null + $user_guid = $null +} + +If ($state -eq 'present') { + # If the account does not exist, create it + If (-not $user_obj) { + $create_args = @{} + $create_args.Name = $name + If ($null -ne $path) { + $create_args.Path = $path + } + If ($null -ne $upn) { + $create_args.UserPrincipalName = $upn + $create_args.SamAccountName = $upn.Split('@')[0] + } + If ($null -ne $sam_account_name) { + $create_args.SamAccountName = $sam_account_name + } + if ($null -ne $password) { + $create_args.AccountPassword = ConvertTo-SecureString $password -AsPlainText -Force + } + $user_obj = New-ADUser @create_args -WhatIf:$check_mode -PassThru @extra_args + $user_guid = $user_obj.ObjectGUID + $module.Result.created = $true + $module.Result.changed = $true + If ($check_mode) { + $module.ExitJson() + } + $user_obj = Get-ADUser -Identity $user_guid -Properties ('*', 'msDS-PrincipalName') @extra_args + } + ElseIf ($password) { + # Don't unnecessary check for working credentials. + # Set the password if we need to. + If ($update_password -eq "always") { + $set_new_credentials = $true + } + elseif ($update_password -eq "when_changed") { + $user_identifier = If ($user_obj.UserPrincipalName) { + $user_obj.UserPrincipalName + } + else { + $user_obj.'msDS-PrincipalName' + } + + $set_new_credentials = -not (Test-Credential -Username $user_identifier -Password $password) + } + else { + $set_new_credentials = $false + } + If ($set_new_credentials) { + $secure_password = ConvertTo-SecureString $password -AsPlainText -Force + try { + Set-ADAccountPassword -Identity $user_guid ` + -Reset:$true ` + -Confirm:$false ` + -NewPassword $secure_password ` + -WhatIf:$check_mode @extra_args + } + catch { + $module.FailJson("Failed to set password on account: $($_.Exception.Message)", $_) + } + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.password_updated = $true + $module.Result.changed = $true + } + } + + # Configure password policies + If (($null -ne $password_never_expires) -and ($password_never_expires -ne $user_obj.PasswordNeverExpires)) { + Set-ADUser -Identity $user_guid -PasswordNeverExpires $password_never_expires -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If (($null -ne $password_expired) -and ($password_expired -ne $user_obj.PasswordExpired)) { + Set-ADUser -Identity $user_guid -ChangePasswordAtLogon $password_expired -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If (($null -ne $user_cannot_change_password) -and ($user_cannot_change_password -ne $user_obj.CannotChangePassword)) { + Set-ADUser -Identity $user_guid -CannotChangePassword $user_cannot_change_password -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + + # Assign other account settings + If (($null -ne $upn) -and ($upn -ne $user_obj.UserPrincipalName)) { + Set-ADUser -Identity $user_guid -UserPrincipalName $upn -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If (($null -ne $sam_account_name) -and ($sam_account_name -ne $user_obj.SamAccountName)) { + Set-ADUser -Identity $user_guid -SamAccountName $sam_account_name -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If (($null -ne $description) -and ($description -ne $user_obj.Description)) { + Set-ADUser -Identity $user_guid -description $description -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If ($enabled -ne $user_obj.Enabled) { + Set-ADUser -Identity $user_guid -Enabled $enabled -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If ((-not $account_locked) -and ($user_obj.LockedOut -eq $true)) { + Unlock-ADAccount -Identity $user_guid -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + If ($delegates) { + if (Compare-Object $delegates $user_obj.PrincipalsAllowedToDelegateToAccount) { + Set-ADUser -Identity $user_guid -PrincipalsAllowedToDelegateToAccount $delegates + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + } + + # configure service principal names + if ($null -ne $spn) { + $current_spn = [Array]$user_obj.ServicePrincipalNames + $desired_spn = [Array]$spn + $spn_diff = @() + + # generate a diff + $desired_spn | ForEach-Object { + if ($current_spn -contains $_) { + $spn_diff += $_ + } + } + + try { + switch ($spn_action) { + "add" { + # the current spn list does not have any spn's in the desired list + if (-not $spn_diff) { + Set-ADUser ` + -Identity $user_guid ` + -ServicePrincipalNames @{ Add = $(($spn | ForEach-Object { "$($_)" } )) } ` + -WhatIf:$check_mode @extra_args + $module.Result.changed = $true + } + } + "remove" { + # the current spn list does not have any differences + # that means we can remove the desired list + if ($spn_diff) { + Set-ADUser ` + -Identity $user_guid ` + -ServicePrincipalNames @{ Remove = $(($spn | ForEach-Object { "$($_)" } )) } ` + -WhatIf:$check_mode @extra_args + $module.Result.changed = $true + } + } + "replace" { + # the current and desired spn lists do not match + if (Compare-Object $current_spn $desired_spn) { + Set-ADUser ` + -Identity $user_guid ` + -ServicePrincipalNames @{ Replace = $(($spn | ForEach-Object { "$($_)" } )) } ` + -WhatIf:$check_mode @extra_args + $module.Result.changed = $true + } + } + } + } + catch { + $module.FailJson("Failed to $spn_action SPN(s)", $_) + } + } + + # Set user information + Foreach ($key in $user_info.Keys) { + If ($null -eq $user_info[$key]) { + continue + } + $value = $user_info[$key] + If ($value -ne $user_obj.$key) { + $set_args = $extra_args.Clone() + $set_args.$key = $value + Set-ADUser -Identity $user_guid -WhatIf:$check_mode @set_args + $module.Result.changed = $true + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + } + } + + # Set additional attributes + $set_args = $extra_args.Clone() + $run_change = $false + + if ($null -ne $attributes) { + $add_attributes = @{} + $replace_attributes = @{} + foreach ($attribute in $attributes.GetEnumerator()) { + $attribute_name = $attribute.Key + $attribute_value = $attribute.Value + + $valid_property = [bool]($user_obj.PSobject.Properties.name -eq $attribute_name) + if ($valid_property) { + $existing_value = $user_obj.$attribute_name + if ($existing_value -cne $attribute_value) { + $replace_attributes.$attribute_name = $attribute_value + } + } + else { + $add_attributes.$attribute_name = $attribute_value + } + } + if ($add_attributes.Count -gt 0) { + $set_args.Add = $add_attributes + $run_change = $true + } + if ($replace_attributes.Count -gt 0) { + $set_args.Replace = $replace_attributes + $run_change = $true + } + } + + if ($run_change) { + Set-ADUser -Identity $user_guid -WhatIf:$check_mode @set_args + $module.Result.changed = $true + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + } + + # Configure group assignment + if ($null -ne $groups) { + $group_list = $groups + + $groups = @( + Foreach ($group in $group_list) { + try { + (Get-ADGroup -Identity $group @extra_args).DistinguishedName + } + catch { + if ($groups_missing_behaviour -eq "fail") { + $module.FailJson("Failed to locate group $($group): $($_.Exception.Message)", $_) + } + elseif ($groups_missing_behaviour -eq "warn") { + $module.Warn("Failed to locate group $($group) but continuing on: $($_.Exception.Message)") + } + } + } + ) + + $assigned_groups = Get-PrincipalGroup $user_guid $extra_args + + switch ($groups_action) { + "add" { + Foreach ($group in $groups) { + If (-not ($assigned_groups -Contains $group)) { + Add-ADGroupMember -Identity $group -Members $user_guid -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + } + } + "remove" { + Foreach ($group in $groups) { + If ($assigned_groups -Contains $group) { + Remove-ADGroupMember -Identity $group -Members $user_guid -Confirm:$false -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + } + } + "replace" { + Foreach ($group in $assigned_groups) { + If (($group -ne $user_obj.PrimaryGroup) -and -not ($groups -Contains $group)) { + Remove-ADGroupMember -Identity $group -Members $user_guid -Confirm:$false -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + } + Foreach ($group in $groups) { + If (-not ($assigned_groups -Contains $group)) { + Add-ADGroupMember -Identity $group -Members $user_guid -WhatIf:$check_mode @extra_args + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.changed = $true + } + } + } + } + } +} +elseif ($state -eq 'absent') { + # Ensure user does not exist + If ($user_obj) { + Remove-ADUser $user_obj -Confirm:$false -WhatIf:$check_mode @extra_args + $module.Result.changed = $true + if ($check_mode) { + $module.ExitJson() + } + $user_obj = $null + } +} + +If ($user_obj) { + $user_obj = Get-ADUser -Identity $user_guid -Properties * @extra_args + $module.Result.name = $user_obj.Name + $module.Result.firstname = $user_obj.GivenName + $module.Result.surname = $user_obj.Surname + $module.Result.display_name = $user_obj.DisplayName + $module.Result.enabled = $user_obj.Enabled + $module.Result.company = $user_obj.Company + $module.Result.street = $user_obj.StreetAddress + $module.Result.email = $user_obj.EmailAddress + $module.Result.city = $user_obj.City + $module.Result.state_province = $user_obj.State + $module.Result.country = $user_obj.Country + $module.Result.postal_code = $user_obj.PostalCode + $module.Result.distinguished_name = $user_obj.DistinguishedName + $module.Result.description = $user_obj.Description + $module.Result.password_expired = $user_obj.PasswordExpired + $module.Result.password_never_expires = $user_obj.PasswordNeverExpires + $module.Result.user_cannot_change_password = $user_obj.CannotChangePassword + $module.Result.account_locked = $user_obj.LockedOut + $module.Result.delegates = $user_obj.PrincipalsAllowedToDelegateToAccount + $module.Result.sid = [string]$user_obj.SID + $module.Result.spn = [Array]$user_obj.ServicePrincipalNames + $module.Result.upn = $user_obj.UserPrincipalName + $module.Result.sam_account_name = $user_obj.SamAccountName + $module.Result.groups = Get-PrincipalGroup $user_guid $extra_args + $module.Result.msg = "User '$name' is present" + $module.Result.state = "present" +} +else { + $module.Result.name = $name + $module.Result.msg = "User '$name' is absent" + $module.Result.state = "absent" +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_domain_user.py b/ansible_collections/community/windows/plugins/modules/win_domain_user.py new file mode 100644 index 00000000..aee5efd0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_domain_user.py @@ -0,0 +1,477 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_domain_user +short_description: Manages Windows Active Directory user accounts +description: + - Manages Windows Active Directory user accounts. +options: + name: + description: + - Name of the user to create, remove or modify. + type: str + required: true + identity: + description: + - Identity parameter used to find the User in the Active Directory. + - This value can be in the forms C(Distinguished Name), C(objectGUID), + C(objectSid) or C(sAMAccountName). + - Default to C(name) if not set. + type: str + state: + description: + - When C(present), creates or updates the user account. + - When C(absent), removes the user account if it exists. + - When C(query), retrieves the user account details without making any changes. + type: str + choices: [ absent, present, query ] + default: present + enabled: + description: + - C(yes) will enable the user account. + - C(no) will disable the account. + type: bool + default: yes + account_locked: + description: + - C(no) will unlock the user account if locked. + - Note that there is not a way to lock an account as an administrator. + - Accounts are locked due to user actions; as an admin, you may only unlock a locked account. + - If you wish to administratively disable an account, set I(enabled) to C(no). + type: bool + description: + description: + - Description of the user + type: str + groups: + description: + - Adds or removes the user from this list of groups, + depending on the value of I(groups_action). + - To remove all but the Principal Group, set C(groups=<principal group name>) and + I(groups_action=replace). + - Note that users cannot be removed from their principal group (for example, "Domain Users"). + type: list + elements: str + groups_action: + description: + - If C(add), the user is added to each group in I(groups) where not already a member. + - If C(remove), the user is removed from each group in I(groups). + - If C(replace), the user is added as a member of each group in + I(groups) and removed from any other groups. + type: str + choices: [ add, remove, replace ] + default: replace + groups_missing_behaviour: + description: + - Controls what happens when a group specified by C(groups) is an invalid group name. + - C(fail) is the default and will return an error any groups do not exist. + - C(ignore) will ignore any groups that does not exist. + - C(warn) will display a warning for any groups that do not exist but will continue without failing. + type: str + choices: + - fail + - ignore + - warn + default: fail + version_added: 1.10.0 + spn: + description: + - Specifies the service principal name(s) for the account. This parameter sets the + ServicePrincipalNames property of the account. The LDAP display name (ldapDisplayName) + for this property is servicePrincipalName. + type: list + elements: str + aliases: [ spns ] + version_added: 1.10.0 + spn_action: + description: + - If C(add), the SPNs are added to the user. + - If C(remove), the SPNs are removed from the user. + - If C(replace), the defined set of SPN's overwrite the current set of SPNs. + type: str + choices: [ add, remove, replace ] + default: replace + version_added: 1.10.0 + password: + description: + - Optionally set the user's password to this (plain text) value. + - To enable an account - I(enabled) - a password must already be + configured on the account, or you must provide a password here. + type: str + update_password: + description: + - C(always) will always update passwords. + - C(on_create) will only set the password for newly created users. + - C(when_changed) will only set the password when changed. + type: str + choices: [ always, on_create, when_changed ] + default: always + password_expired: + description: + - C(yes) will require the user to change their password at next login. + - C(no) will clear the expired password flag. + - This is mutually exclusive with I(password_never_expires). + type: bool + password_never_expires: + description: + - C(yes) will set the password to never expire. + - C(no) will allow the password to expire. + - This is mutually exclusive with I(password_expired). + type: bool + user_cannot_change_password: + description: + - C(yes) will prevent the user from changing their password. + - C(no) will allow the user to change their password. + type: bool + firstname: + description: + - Configures the user's first name (given name). + type: str + surname: + description: + - Configures the user's last name (surname). + type: str + aliases: [ lastname ] + display_name: + description: + - Configures the user's display name. + type: str + version_added: 1.12.0 + company: + description: + - Configures the user's company name. + type: str + upn: + description: + - Configures the User Principal Name (UPN) for the account. + - This is not required, but is best practice to configure for modern + versions of Active Directory. + - The format is C(<username>@<domain>). + type: str + sam_account_name: + description: + - Configures the SAM Account Name (C(sAMAccountName)) for the account. + - This is allowed to a maximum of 20 characters due to pre-Windows 2000 restrictions. + - Default to the C(<username>) specified in C(upn) or C(name) if not set. + type: str + version_added: 1.7.0 + email: + description: + - Configures the user's email address. + - This is a record in AD and does not do anything to configure any email + servers or systems. + type: str + street: + description: + - Configures the user's street address. + type: str + city: + description: + - Configures the user's city. + type: str + state_province: + description: + - Configures the user's state or province. + type: str + postal_code: + description: + - Configures the user's postal code / zip code. + type: str + country: + description: + - Configures the user's country code. + - Note that this is a two-character ISO 3166 code. + type: str + path: + description: + - Container or OU for the new user; if you do not specify this, the + user will be placed in the default container for users in the domain. + - Setting the path is only available when a new user is created; + if you specify a path on an existing user, the user's path will not + be updated - you must delete (e.g., C(state=absent)) the user and + then re-add the user with the appropriate path. + type: str + delegates: + description: + - Specifies an array of principal objects. This parameter sets the + msDS-AllowedToActOnBehalfOfOtherIdentity attribute of a computer account + object. + - Must be specified as a distinguished name C(CN=shenetworks,CN=Users,DC=ansible,DC=test) + type: list + elements: str + aliases: [ principals_allowed_to_delegate ] + version_added: 1.10.0 + attributes: + description: + - A dict of custom LDAP attributes to set on the user. + - This can be used to set custom attributes that are not exposed as module + parameters, e.g. C(telephoneNumber). + - See the examples on how to format this parameter. + type: dict + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user Ansible used to log in with will be + used instead when using CredSSP or Kerberos with credential delegation. + type: str + domain_password: + description: + - The password for I(username). + type: str + domain_server: + description: + - Specifies the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the domain of the computer + running PowerShell. + type: str +notes: + - Works with Windows 2012R2 and newer. + - If running on a server that is not a Domain Controller, credential + delegation through CredSSP or Kerberos with delegation must be used or the + I(domain_username), I(domain_password) must be set. + - Note that some individuals have confirmed successful operation on Windows + 2008R2 servers with AD and AD Web Services enabled, but this has not + received the same degree of testing as Windows 2012R2. +seealso: +- module: ansible.windows.win_domain +- module: ansible.windows.win_domain_controller +- module: community.windows.win_domain_computer +- module: community.windows.win_domain_group +- module: ansible.windows.win_domain_membership +- module: ansible.windows.win_user +- module: community.windows.win_user_profile +author: + - Nick Chandler (@nwchandler) + - Joe Zollo (@zollo) +''' + +EXAMPLES = r''' +- name: Ensure user bob is present with address information + community.windows.win_domain_user: + name: bob + firstname: Bob + surname: Smith + display_name: Mr. Bob Smith + company: BobCo + password: B0bP4ssw0rd + state: present + groups: + - Domain Admins + street: 123 4th St. + city: Sometown + state_province: IN + postal_code: 12345 + country: US + attributes: + telephoneNumber: 555-123456 + +- name: Ensure user bob is created and use custom credentials to create the user + community.windows.win_domain_user: + name: bob + firstname: Bob + surname: Smith + password: B0bP4ssw0rd + state: present + domain_username: DOMAIN\admin-account + domain_password: SomePas2w0rd + domain_server: domain@DOMAIN.COM + +- name: Ensure user bob is present in OU ou=test,dc=domain,dc=local + community.windows.win_domain_user: + name: bob + password: B0bP4ssw0rd + state: present + path: ou=test,dc=domain,dc=local + groups: + - Domain Admins + +- name: Ensure user bob is absent + community.windows.win_domain_user: + name: bob + state: absent + +- name: Ensure user has spn's defined + community.windows.win_domain_user: + name: liz.kenyon + spn: + - MSSQLSvc/us99db-svr95:1433 + - MSSQLSvc/us99db-svr95.vmware.com:1433 + +- name: Ensure user has spn added + community.windows.win_domain_user: + name: liz.kenyon + spn_action: add + spn: + - MSSQLSvc/us99db-svr95:2433 + +- name: Ensure user is created with delegates and spn's defined + community.windows.win_domain_user: + name: shmemmmy + password: The3rubberducki33! + state: present + groups: + - Domain Admins + - Enterprise Admins + delegates: + - CN=shenetworks,CN=Users,DC=ansible,DC=test + - CN=mk.ai,CN=Users,DC=ansible,DC=test + - CN=jessiedotjs,CN=Users,DC=ansible,DC=test + spn: + - MSSQLSvc/us99db-svr95:2433 +''' + +RETURN = r''' +account_locked: + description: true if the account is locked + returned: always + type: bool + sample: false +changed: + description: true if the account changed during execution + returned: always + type: bool + sample: false +city: + description: The user city + returned: always + type: str + sample: Indianapolis +company: + description: The user company + returned: always + type: str + sample: RedHat +country: + description: The user country + returned: always + type: str + sample: US +delegates: + description: Principals allowed to delegate + returned: always + type: list + elements: str + sample: + - CN=svc.tech.unicorn,CN=Users,DC=ansible,DC=test + - CN=geoff,CN=Users,DC=ansible,DC=test + version_added: 1.10.0 +description: + description: A description of the account + returned: always + type: str + sample: Server Administrator +display_name: + description: The user display name + returned: always + type: str + sample: Nick Doe +distinguished_name: + description: DN of the user account + returned: always + type: str + sample: CN=nick,OU=test,DC=domain,DC=local +email: + description: The user email address + returned: always + type: str + sample: nick@domain.local +enabled: + description: true if the account is enabled and false if disabled + returned: always + type: str + sample: true +firstname: + description: The user first name + returned: always + type: str + sample: Nick +groups: + description: AD Groups to which the account belongs + returned: always + type: list + sample: [ "Domain Admins", "Domain Users" ] +msg: + description: Summary message of whether the user is present or absent + returned: always + type: str + sample: User nick is present +name: + description: The username on the account + returned: always + type: str + sample: nick +password_expired: + description: true if the account password has expired + returned: always + type: bool + sample: false +password_updated: + description: true if the password changed during this execution + returned: always + type: bool + sample: true +postal_code: + description: The user postal code + returned: always + type: str + sample: 46033 +sid: + description: The SID of the account + returned: always + type: str + sample: S-1-5-21-2752426336-228313920-2202711348-1175 +spn: + description: The service principal names + returned: always + type: list + sample: + - HTTPSvc/ws1intel-svc1 + - HTTPSvc/ws1intel-svc1.vmware.com + version_added: 1.10.0 +state: + description: The state of the user account + returned: always + type: str + sample: present +state_province: + description: The user state or province + returned: always + type: str + sample: IN +street: + description: The user street address + returned: always + type: str + sample: 123 4th St. +surname: + description: The user last name + returned: always + type: str + sample: Doe +upn: + description: The User Principal Name of the account + returned: always + type: str + sample: nick@domain.local +sam_account_name: + description: The SAM Account Name of the account + returned: always + type: str + sample: nick + version_added: 1.7.0 +user_cannot_change_password: + description: true if the user is not allowed to change password + returned: always + type: str + sample: false +created: + description: Whether a user was created + returned: always + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.ps1 b/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.ps1 new file mode 100644 index 00000000..50405925 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.ps1 @@ -0,0 +1,65 @@ +#!powershell + +# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$result = @{ + changed = $false +} + +Function Invoke-Ngen($architecture = "") { + $cmd = "$($env:windir)\Microsoft.NET\Framework$($architecture)\v4.0.30319\ngen.exe" + + if (Test-Path -LiteralPath $cmd) { + $arguments = "update /force" + if ($check_mode) { + $ngen_result = @{ + rc = 0 + stdout = "check mode output for $cmd $arguments" + } + } + else { + try { + $ngen_result = Run-Command -command "$cmd $arguments" + } + catch { + Fail-Json -obj $result -message "failed to execute '$cmd $arguments': $($_.Exception.Message)" + } + } + $result."dotnet_ngen$($architecture)_update_exit_code" = $ngen_result.rc + $result."dotnet_ngen$($architecture)_update_output" = $ngen_result.stdout + + $arguments = "executeQueuedItems" + if ($check_mode) { + $executed_queued_items = @{ + rc = 0 + stdout = "check mode output for $cmd $arguments" + } + } + else { + try { + $executed_queued_items = Run-Command -command "$cmd $arguments" + } + catch { + Fail-Json -obj $result -message "failed to execute '$cmd $arguments': $($_.Exception.Message)" + } + } + $result."dotnet_ngen$($architecture)_eqi_exit_code" = $executed_queued_items.rc + $result."dotnet_ngen$($architecture)_eqi_output" = $executed_queued_items.stdout + $result.changed = $true + } +} + +Invoke-Ngen +Invoke-Ngen -architecture "64" + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.py b/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.py new file mode 100644 index 00000000..d207bafd --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_dotnet_ngen.py @@ -0,0 +1,80 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_dotnet_ngen +short_description: Runs ngen to recompile DLLs after .NET updates +description: + - After .NET framework is installed/updated, Windows will probably want to recompile things to optimise for the host. + - This happens via scheduled task, usually at some inopportune time. + - This module allows you to run this task on your own schedule, so you incur the CPU hit at some more convenient and controlled time. + - U(https://docs.microsoft.com/en-us/dotnet/framework/tools/ngen-exe-native-image-generator#native-image-service) + - U(http://blogs.msdn.com/b/dotnet/archive/2013/08/06/wondering-why-mscorsvw-exe-has-high-cpu-usage-you-can-speed-it-up.aspx) +options: {} +notes: + - There are in fact two scheduled tasks for ngen but they have no triggers so aren't a problem. + - There's no way to test if they've been completed. + - The stdout is quite likely to be several megabytes. +author: +- Peter Mounce (@petemounce) +''' + +EXAMPLES = r''' +- name: Run ngen tasks + community.windows.win_dotnet_ngen: +''' + +RETURN = r''' +dotnet_ngen_update_exit_code: + description: The exit code after running the 32-bit ngen.exe update /force + command. + returned: 32-bit ngen executable exists + type: int + sample: 0 +dotnet_ngen_update_output: + description: The stdout after running the 32-bit ngen.exe update /force + command. + returned: 32-bit ngen executable exists + type: str + sample: sample output +dotnet_ngen_eqi_exit_code: + description: The exit code after running the 32-bit ngen.exe + executeQueuedItems command. + returned: 32-bit ngen executable exists + type: int + sample: 0 +dotnet_ngen_eqi_output: + description: The stdout after running the 32-bit ngen.exe executeQueuedItems + command. + returned: 32-bit ngen executable exists + type: str + sample: sample output +dotnet_ngen64_update_exit_code: + description: The exit code after running the 64-bit ngen.exe update /force + command. + returned: 64-bit ngen executable exists + type: int + sample: 0 +dotnet_ngen64_update_output: + description: The stdout after running the 64-bit ngen.exe update /force + command. + returned: 64-bit ngen executable exists + type: str + sample: sample output +dotnet_ngen64_eqi_exit_code: + description: The exit code after running the 64-bit ngen.exe + executeQueuedItems command. + returned: 64-bit ngen executable exists + type: int + sample: 0 +dotnet_ngen64_eqi_output: + description: The stdout after running the 64-bit ngen.exe executeQueuedItems + command. + returned: 64-bit ngen executable exists + type: str + sample: sample output +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_eventlog.ps1 b/ansible_collections/community/windows/plugins/modules/win_eventlog.ps1 new file mode 100644 index 00000000..a2388d9b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_eventlog.ps1 @@ -0,0 +1,287 @@ +#!powershell + +# Copyright: (c) 2017, Andrew Saraceni <andrew.saraceni@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +function Get-EventLogDetail { + <# + .SYNOPSIS + Get details of an event log, sources, and associated attributes. + Used for comparison against passed-in option values to ensure idempotency. + #> + param( + [String]$LogName + ) + + $log_details = @{} + $log_details.name = $LogName + $log_details.exists = $false + $log = Get-EventLog -List | Where-Object { $_.Log -eq $LogName } + + if ($log) { + $log_details.exists = $true + $log_details.maximum_size_kb = $log.MaximumKilobytes + $log_details.overflow_action = $log.OverflowAction.ToString() + $log_details.retention_days = $log.MinimumRetentionDays + $log_details.entries = $log.Entries.Count + $log_details.sources = [Ordered]@{} + + # Retrieve existing sources and category/message/parameter file locations + # Associating file locations and sources with logs can only be done from the registry + + $root_key = "HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\{0}" -f $LogName + $log_root = Get-ChildItem -LiteralPath $root_key + + foreach ($child in $log_root) { + $source_name = $child.PSChildName + $log_details.sources.$source_name = @{} + $hash_cursor = $log_details.sources.$source_name + + $source_root = "{0}\{1}" -f $root_key, $source_name + $resource_files = Get-ItemProperty -LiteralPath $source_root + + $hash_cursor.category_file = $resource_files.CategoryMessageFile + $hash_cursor.message_file = $resource_files.EventMessageFile + $hash_cursor.parameter_file = $resource_files.ParameterMessageFile + } + } + + return $log_details +} + +function Test-SourceExistence { + <# + .SYNOPSIS + Get information on a source's existence. + Examine existence regarding the parent log it belongs to and its expected state. + #> + param( + [String]$LogName, + [String]$SourceName, + [Switch]$NoLogShouldExist + ) + + $source_exists = [System.Diagnostics.EventLog]::SourceExists($SourceName) + + if ($source_exists -and $NoLogShouldExist) { + Fail-Json -obj $result -message "Source $SourceName already exists and cannot be created" + } + elseif ($source_exists) { + $source_log = [System.Diagnostics.EventLog]::LogNameFromSourceName($SourceName, ".") + if ($source_log -ne $LogName) { + Fail-Json -obj $result -message "Source $SourceName does not belong to log $LogName and cannot be modified" + } + } + + return $source_exists +} + +function ConvertTo-MaximumSize { + <# + .SYNOPSIS + Convert a string KB/MB/GB value to common bytes and KB representations. + .NOTES + Size must be between 64KB and 4GB and divisible by 64KB, as per the MaximumSize parameter of Limit-EventLog. + #> + param( + [String]$Size + ) + + $parsed_size = @{ + bytes = $null + KB = $null + } + + $size_regex = "^\d+(\.\d+)?(KB|MB|GB)$" + if ($Size -notmatch $size_regex) { + Fail-Json -obj $result -message "Maximum size $Size is not properly specified" + } + + $size_upper = $Size.ToUpper() + $size_numeric = [Double]$Size.Substring(0, $Size.Length - 2) + + if ($size_upper.EndsWith("GB")) { + $size_bytes = $size_numeric * 1GB + } + elseif ($size_upper.EndsWith("MB")) { + $size_bytes = $size_numeric * 1MB + } + elseif ($size_upper.EndsWith("KB")) { + $size_bytes = $size_numeric * 1KB + } + + if (($size_bytes -lt 64KB) -or ($size_bytes -ge 4GB)) { + Fail-Json -obj $result -message "Maximum size must be between 64KB and 4GB" + } + elseif (($size_bytes % 64KB) -ne 0) { + Fail-Json -obj $result -message "Maximum size must be divisible by 64KB" + } + + $parsed_size.bytes = $size_bytes + $parsed_size.KB = $size_bytes / 1KB + return $parsed_size +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "clear", "absent" +$sources = Get-AnsibleParam -obj $params -name "sources" -type "list" +$category_file = Get-AnsibleParam -obj $params -name "category_file" -type "path" +$message_file = Get-AnsibleParam -obj $params -name "message_file" -type "path" +$parameter_file = Get-AnsibleParam -obj $params -name "parameter_file" -type "path" +$maximum_size = Get-AnsibleParam -obj $params -name "maximum_size" -type "str" +$overflow_action = Get-AnsibleParam -obj $params -name "overflow_action" -type "str" -validateset "OverwriteOlder", "OverwriteAsNeeded", "DoNotOverwrite" +$retention_days = Get-AnsibleParam -obj $params -name "retention_days" -type "int" + +$result = @{ + changed = $false + name = $name + sources_changed = @() +} + +$log_details = Get-EventLogDetail -LogName $name + +# Handle common error cases up front +if ($state -eq "present" -and !$log_details.exists -and !$sources) { + # When creating a log, one or more sources must be passed + Fail-Json -obj $result -message "You must specify one or more sources when creating a log for the first time" +} +elseif ($state -eq "present" -and $log_details.exists -and $name -in $sources -and ($category_file -or $message_file -or $parameter_file)) { + # After a default source of the same name is created, it cannot be modified without removing the log + Fail-Json -obj $result -message "Cannot modify default source $name of log $name - you must remove the log" +} +elseif ($state -eq "clear" -and !$log_details.exists) { + Fail-Json -obj $result -message "Cannot clear log $name as it does not exist" +} +elseif ($state -eq "absent" -and $name -in $sources) { + # You also cannot remove a default source for the log - you must remove the log itself + Fail-Json -obj $result -message "Cannot remove default source $name from log $name - you must remove the log" +} + +try { + switch ($state) { + "present" { + foreach ($source in $sources) { + if ($log_details.exists) { + $source_exists = Test-SourceExistence -LogName $name -SourceName $source + } + else { + $source_exists = Test-SourceExistence -LogName $name -SourceName $source -NoLogShouldExist + } + + if ($source_exists) { + $category_change = $category_file -and $log_details.sources.$source.category_file -ne $category_file + $message_change = $message_file -and $log_details.sources.$source.message_file -ne $message_file + $parameter_change = $parameter_file -and $log_details.sources.$source.parameter_file -ne $parameter_file + # Remove source and recreate later if any of the above are true + if ($category_change -or $message_change -or $parameter_change) { + Remove-EventLog -Source $source -WhatIf:$check_mode + } + else { + continue + } + } + + $new_params = @{ + LogName = $name + Source = $source + } + if ($category_file) { + $new_params.CategoryResourceFile = $category_file + } + if ($message_file) { + $new_params.MessageResourceFile = $message_file + } + if ($parameter_file) { + $new_params.ParameterResourceFile = $parameter_file + } + + if (!$check_mode) { + New-EventLog @new_params + $result.sources_changed += $source + } + $result.changed = $true + } + + if ($maximum_size) { + $converted_size = ConvertTo-MaximumSize -Size $maximum_size + } + + $size_change = $maximum_size -and $log_details.maximum_size_kb -ne $converted_size.KB + $overflow_change = $overflow_action -and $log_details.overflow_action -ne $overflow_action + $retention_change = $retention_days -and $log_details.retention_days -ne $retention_days + + if ($size_change -or $overflow_change -or $retention_change) { + $limit_params = @{ + LogName = $name + WhatIf = $check_mode + } + if ($maximum_size) { + $limit_params.MaximumSize = $converted_size.bytes + } + if ($overflow_action) { + $limit_params.OverflowAction = $overflow_action + } + if ($retention_days) { + $limit_params.RetentionDays = $retention_days + } + + Limit-EventLog @limit_params + $result.changed = $true + } + + } + "clear" { + if ($log_details.entries -gt 0) { + Clear-EventLog -LogName $name -WhatIf:$check_mode + $result.changed = $true + } + } + "absent" { + if ($sources -and $log_details.exists) { + # Since sources were passed, remove sources tied to event log + foreach ($source in $sources) { + $source_exists = Test-SourceExistence -LogName $name -SourceName $source + if ($source_exists) { + Remove-EventLog -Source $source -WhatIf:$check_mode + if (!$check_mode) { + $result.sources_changed += $source + } + $result.changed = $true + } + } + } + elseif ($log_details.exists) { + # Only name passed, so remove event log itself (which also removes contained sources) + Remove-EventLog -LogName $name -WhatIf:$check_mode + if (!$check_mode) { + $log_details.sources.GetEnumerator() | ForEach-Object { $result.sources_changed += $_.Name } + } + $result.changed = $true + } + } + } +} +catch { + Fail-Json -obj $result -message $_.Exception.Message +} + +$final_log_details = Get-EventLogDetail -LogName $name +foreach ($final_log_detail in $final_log_details.GetEnumerator()) { + if ($final_log_detail.Name -eq "sources") { + $sources = @() + $final_log_detail.Value.GetEnumerator() | ForEach-Object { $sources += $_.Name } + $result.$($final_log_detail.Name) = [Array]$sources + } + else { + $result.$($final_log_detail.Name) = $final_log_detail.Value + } +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_eventlog.py b/ansible_collections/community/windows/plugins/modules/win_eventlog.py new file mode 100644 index 00000000..8cc9b354 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_eventlog.py @@ -0,0 +1,159 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Andrew Saraceni <andrew.saraceni@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_eventlog +short_description: Manage Windows event logs +description: + - Allows the addition, clearing and removal of local Windows event logs, + and the creation and removal of sources from a given event log. Also + allows the specification of settings per log and source. +options: + name: + description: + - Name of the event log to manage. + type: str + required: yes + state: + description: + - Desired state of the log and/or sources. + - When C(sources) is populated, state is checked for sources. + - When C(sources) is not populated, state is checked for the specified log itself. + - If C(state) is C(clear), event log entries are cleared for the target log. + type: str + choices: [ absent, clear, present ] + default: present + sources: + description: + - A list of one or more sources to ensure are present/absent in the log. + - When C(category_file), C(message_file) and/or C(parameter_file) are specified, + these values are applied across all sources. + type: list + elements: str + category_file: + description: + - For one or more sources specified, the path to a custom category resource file. + type: path + message_file: + description: + - For one or more sources specified, the path to a custom event message resource file. + type: path + parameter_file: + description: + - For one or more sources specified, the path to a custom parameter resource file. + type: path + maximum_size: + description: + - The maximum size of the event log. + - Value must be between 64KB and 4GB, and divisible by 64KB. + - Size can be specified in KB, MB or GB (e.g. 128KB, 16MB, 2.5GB). + type: str + overflow_action: + description: + - The action for the log to take once it reaches its maximum size. + - For C(DoNotOverwrite), all existing entries are kept and new entries are not retained. + - For C(OverwriteAsNeeded), each new entry overwrites the oldest entry. + - For C(OverwriteOlder), new log entries overwrite those older than the C(retention_days) value. + type: str + choices: [ DoNotOverwrite, OverwriteAsNeeded, OverwriteOlder ] + retention_days: + description: + - The minimum number of days event entries must remain in the log. + - This option is only used when C(overflow_action) is C(OverwriteOlder). + type: int +seealso: +- module: community.windows.win_eventlog_entry +author: + - Andrew Saraceni (@andrewsaraceni) +''' + +EXAMPLES = r''' +- name: Add a new event log with two custom sources + community.windows.win_eventlog: + name: MyNewLog + sources: + - NewLogSource1 + - NewLogSource2 + state: present + +- name: Change the category and message resource files used for NewLogSource1 + community.windows.win_eventlog: + name: MyNewLog + sources: + - NewLogSource1 + category_file: C:\NewApp\CustomCategories.dll + message_file: C:\NewApp\CustomMessages.dll + state: present + +- name: Change the maximum size and overflow action for MyNewLog + community.windows.win_eventlog: + name: MyNewLog + maximum_size: 16MB + overflow_action: DoNotOverwrite + state: present + +- name: Clear event entries for MyNewLog + community.windows.win_eventlog: + name: MyNewLog + state: clear + +- name: Remove NewLogSource2 from MyNewLog + community.windows.win_eventlog: + name: MyNewLog + sources: + - NewLogSource2 + state: absent + +- name: Remove MyNewLog and all remaining sources + community.windows.win_eventlog: + name: MyNewLog + state: absent +''' + +RETURN = r''' +name: + description: The name of the event log. + returned: always + type: str + sample: MyNewLog +exists: + description: Whether the event log exists or not. + returned: success + type: bool + sample: true +entries: + description: The count of entries present in the event log. + returned: success + type: int + sample: 50 +maximum_size_kb: + description: Maximum size of the log in KB. + returned: success + type: int + sample: 512 +overflow_action: + description: The action the log takes once it reaches its maximum size. + returned: success + type: str + sample: OverwriteOlder +retention_days: + description: The minimum number of days entries are retained in the log. + returned: success + type: int + sample: 7 +sources: + description: A list of the current sources for the log. + returned: success + type: list + sample: ["MyNewLog", "NewLogSource1", "NewLogSource2"] +sources_changed: + description: A list of sources changed (e.g. re/created, removed) for the log; + this is empty if no sources are changed. + returned: always + type: list + sample: ["NewLogSource2"] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.ps1 b/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.ps1 new file mode 100644 index 00000000..3934ecf3 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.ps1 @@ -0,0 +1,106 @@ +#!powershell + +# Copyright: (c) 2017, Andrew Saraceni <andrew.saraceni@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +function Test-LogExistence { + <# + .SYNOPSIS + Get information on a log's existence. + #> + param( + [String]$LogName + ) + + $log_exists = $false + $log = Get-EventLog -List | Where-Object { $_.Log -eq $LogName } + if ($log) { + $log_exists = $true + } + return $log_exists +} + +function Test-SourceExistence { + <# + .SYNOPSIS + Get information on a source's existence. + #> + param( + [String]$LogName, + [String]$SourceName + ) + + $source_exists = [System.Diagnostics.EventLog]::SourceExists($SourceName) + + if ($source_exists) { + $source_log = [System.Diagnostics.EventLog]::LogNameFromSourceName($SourceName, ".") + if ($source_log -ne $LogName) { + Fail-Json -obj $result -message "Source $SourceName does not belong to log $LogName and cannot be written to" + } + } + + return $source_exists +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$log = Get-AnsibleParam -obj $params -name "log" -type "str" -failifempty $true +$source = Get-AnsibleParam -obj $params -name "source" -type "str" -failifempty $true +$event_id = Get-AnsibleParam -obj $params -name "event_id" -type "int" -failifempty $true +$message = Get-AnsibleParam -obj $params -name "message" -type "str" -failifempty $true +$entry_type = Get-AnsibleParam -obj $params -name "entry_type" -type "str" -validateset "Error", "FailureAudit", "Information", "SuccessAudit", "Warning" +$category = Get-AnsibleParam -obj $params -name "category" -type "int" +$raw_data = Get-AnsibleParam -obj $params -name "raw_data" -type "str" + +$result = @{ + changed = $false +} + +$log_exists = Test-LogExistence -LogName $log +if (!$log_exists) { + Fail-Json -obj $result -message "Log $log does not exist and cannot be written to" +} + +$source_exists = Test-SourceExistence -LogName $log -SourceName $source +if (!$source_exists) { + Fail-Json -obj $result -message "Source $source does not exist" +} + +if ($event_id -lt 0 -or $event_id -gt 65535) { + Fail-Json -obj $result -message "Event ID must be between 0 and 65535" +} + +$write_params = @{ + LogName = $log + Source = $source + EventId = $event_id + Message = $message +} + +try { + if ($entry_type) { + $write_params.EntryType = $entry_type + } + if ($category) { + $write_params.Category = $category + } + if ($raw_data) { + $write_params.RawData = [Byte[]]($raw_data -split ",") + } + + if (!$check_mode) { + Write-EventLog @write_params + } + $result.changed = $true + $result.msg = "Entry added to log $log from source $source" +} +catch { + Fail-Json -obj $result -message $_.Exception.Message +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.py b/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.py new file mode 100644 index 00000000..2d474d58 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_eventlog_entry.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Andrew Saraceni <andrew.saraceni@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_eventlog_entry +short_description: Write entries to Windows event logs +description: + - Write log entries to a given event log from a specified source. +options: + log: + description: + - Name of the event log to write an entry to. + type: str + required: yes + source: + description: + - Name of the log source to indicate where the entry is from. + type: str + required: yes + event_id: + description: + - The numeric event identifier for the entry. + - Value must be between 0 and 65535. + type: int + required: yes + message: + description: + - The message for the given log entry. + type: str + required: yes + entry_type: + description: + - Indicates the entry being written to the log is of a specific type. + type: str + choices: [ Error, FailureAudit, Information, SuccessAudit, Warning ] + category: + description: + - A numeric task category associated with the category message file for the log source. + type: int + raw_data: + description: + - Binary data associated with the log entry. + - Value must be a comma-separated array of 8-bit unsigned integers (0 to 255). + type: str +notes: + - This module will always report a change when writing an event entry. +seealso: +- module: community.windows.win_eventlog +author: + - Andrew Saraceni (@andrewsaraceni) +''' + +EXAMPLES = r''' +- name: Write an entry to a Windows event log + community.windows.win_eventlog_entry: + log: MyNewLog + source: NewLogSource1 + event_id: 1234 + message: This is a test log entry. + +- name: Write another entry to a different Windows event log + community.windows.win_eventlog_entry: + log: AnotherLog + source: MyAppSource + event_id: 5000 + message: An error has occurred. + entry_type: Error + category: 5 + raw_data: 10,20 +''' + +RETURN = r''' +# Default return values +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_feature_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_feature_info.ps1 new file mode 100644 index 00000000..4942a575 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_feature_info.ps1 @@ -0,0 +1,45 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str"; default = '*' } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$name = $module.Params.name + +$module.Result.exists = $false + +$features = Get-WindowsFeature -Name $name + +$module.Result.features = @(foreach ($feature in ($features)) { + # These should closely reflect the options for win_feature + @{ + name = $feature.Name + display_name = $feature.DisplayName + description = $feature.Description + installed = $feature.Installed + install_state = $feature.InstallState.ToString() + feature_type = $feature.FeatureType + path = $feature.Path + depth = $feature.Depth + depends_on = $feature.DependsOn + parent = $feature.Parent + server_component_descriptor = $feature.ServerComponentDescriptor + sub_features = $feature.SubFeatures + system_service = $feature.SystemService + best_practices_model_id = $feature.BestPracticesModelId + event_query = $feature.EventQuery + post_configuration_needed = $feature.PostConfigurationNeeded + additional_info = $feature.AdditionalInfo + } + $module.Result.exists = $true + }) + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_feature_info.py b/ansible_collections/community/windows/plugins/modules/win_feature_info.py new file mode 100644 index 00000000..c35d8c36 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_feature_info.py @@ -0,0 +1,166 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_feature_info +version_added: '1.4.0' +short_description: Gather information about Windows features +description: +- Gather information about all or a specific installed Windows feature(s). +options: + name: + description: + - If specified, this is used to match the C(name) of the Windows feature to get the info for. + - Can be a wildcard to match multiple features but the wildcard will only be matched on the C(name) of the feature. + - If omitted then all features will returned. + type: str + default: '*' +seealso: +- module: ansible.windows.win_feature +author: +- Larry Lane (@gamethis) +''' + +EXAMPLES = r''' +- name: Get info for all installed features + community.windows.win_feature_info: + register: feature_info +- name: Get info for a single feature + community.windows.win_feature_info: + name: DNS + register: feature_info +- name: Find all features that start with 'FS' + ansible.windows.win_feature_info: + name: FS* +''' + +RETURN = r''' +exists: + description: Whether any features were found based on the criteria specified. + returned: always + type: bool + sample: true +features: + description: + - A list of feature(s) that were found based on the criteria. + - Will be an empty list if no features were found. + returned: always + type: list + elements: dict + contains: + name: + description: + - Name of feature found. + type: str + sample: AD-Certificate + display_name: + description: + - The Display name of feature found. + type: str + sample: Active Directory Certificate Services + description: + description: + - The description of the feature. + type: str + sample: Example description of the Windows feature. + installed: + description: + - Whether the feature by C(name) is installed. + type: bool + sample: false + install_state: + description: + - The Install State of C(name). + - Values will be one of C(Available), C(Removed), C(Installed). + type: str + sample: Installed + feature_type: + description: + - The Feature Type of C(name). + - Values will be one of C(Role), C(Role Service), C(Feature). + type: str + sample: Feature + path: + description: + - The Path of C(name) feature. + type: str + sample: WoW64 Support + depth: + description: + - Depth of C(name) feature. + type: int + sample: 1 + depends_on: + description: + - The command line that will be run when a C(run_command) failure action is fired. + type: list + elements: str + sample: ['Web-Static-Content', 'Web-Default-Doc'] + parent: + description: + - The parent of feature C(name) if present. + type: str + sample: PowerShellRoot + server_component_descriptor: + description: + - Descriptor of C(name) feature. + type: str + sample: ServerComponent_AD_Certificate + sub_features: + description: + - List of sub features names of feature C(name). + type: list + elements: str + sample: ['WAS-Process-Model', 'WAS-NET-Environment', 'WAS-Config-APIs'] + system_service: + description: + - The name of the service installed by feature C(name). + type: list + elements: str + sample: ['iisadmin', 'w3svc'] + best_practices_model_id: + description: + - BestPracticesModelId for feature C(name). + type: str + sample: Microsoft/Windows/UpdateServices + event_query: + description: + - The EventQuery for feature C(name). + - This will be C(null) if None Present + type: str + sample: IPAMServer.Events.xml + post_configuration_needed: + description: + - Tells if Post Configuration is needed for feature C(name). + type: bool + sample: False + additional_info: + description: + - A list of privileges that the feature requires and will run with + type: dict + contains: + major_version: + description: + - Major Version of feature C(name). + type: int + sample: 8 + minor_version: + description: + - Minor Version of feature C(name). + type: int + sample: 0 + number_id_version: + description: + - Numberic Id of feature C(name). + type: int + sample: 16 + install_name: + description: + - The action to perform once triggered, can be C(start_feature) or C(stop_feature). + type: str + sample: ADCertificateServicesRole +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_file_compression.ps1 b/ansible_collections/community/windows/plugins/modules/win_file_compression.ps1 new file mode 100644 index 00000000..de905922 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_file_compression.ps1 @@ -0,0 +1,120 @@ +#!powershell + +# Copyright: (c) 2019, Micah Hunsberger +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +Set-StrictMode -Version 2 + +$spec = @{ + options = @{ + path = @{ type = 'path'; required = $true } + state = @{ type = 'str'; default = 'present'; choices = 'absent', 'present' } + recurse = @{ type = 'bool'; default = $false } + force = @{ type = 'bool'; default = $true } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$path = $module.Params.path +$state = $module.Params.state +$recurse = $module.Params.recurse +$force = $module.Params.force + +$module.Result.rc = 0 + +if (-not (Test-Path -LiteralPath $path)) { + $module.FailJson("Path to item, $path, does not exist.") +} + +$item = Get-Item -LiteralPath $path -Force # Use -Force for hidden files +if (-not $item.PSIsContainer -and $recurse) { + $module.Warn("The recurse option has no effect when path is not a folder.") +} + +$cim_params = @{ + ClassName = 'Win32_LogicalDisk' + Filter = "DeviceId='$($item.PSDrive.Name):'" + Property = @('FileSystem', 'SupportsFileBasedCompression') +} +$drive_info = Get-CimInstance @cim_params +if ($drive_info.SupportsFileBasedCompression -eq $false) { + $module.FailJson("Path, $path, is not on a filesystemi '$($drive_info.FileSystem)' that supports file based compression.") +} + +function Get-ReturnCodeMessage { + param( + [int]$code + ) + switch ($code) { + 0 { return "The request was successful." } + 2 { return "Access was denied." } + 8 { return "An unspecified failure occurred." } + 9 { return "The name specified was not valid." } + 10 { return "The object specified already exists." } + 11 { return "The file system is not NTFS." } + 12 { return "The platform is not Windows." } + 13 { return "The drive is not the same." } + 14 { return "The directory is not empty." } + 15 { return "There has been a sharing violation." } + 16 { return "The start file specified was not valid." } + 17 { return "A privilege required for the operation is not held." } + 21 { return "A parameter specified is not valid." } + } +} + +function Get-EscapedFileName { + param( + [string]$FullName + ) + return $FullName.Replace("\", "\\").Replace("'", "\'") +} + +$is_compressed = ($item.Attributes -band [System.IO.FileAttributes]::Compressed) -eq [System.IO.FileAttributes]::Compressed +$needs_changed = $is_compressed -ne ($state -eq 'present') + +if ($force -and $recurse -and $item.PSIsContainer) { + if (-not $needs_changed) { + # Check the subfolders and files + $entries_to_check = $item.EnumerateFileSystemInfos("*", [System.IO.SearchOption]::AllDirectories) + foreach ($entry in $entries_to_check) { + $is_compressed = ($entry.Attributes -band [System.IO.FileAttributes]::Compressed) -eq [System.IO.FileAttributes]::Compressed + if ($is_compressed -ne ($state -eq 'present')) { + $needs_changed = $true + break + } + } + } +} + +if ($needs_changed) { + $module.Result.changed = $true + if ($item.PSIsContainer) { + $cim_obj = Get-CimInstance -ClassName 'Win32_Directory' -Filter "Name='$(Get-EscapedFileName -FullName $item.FullName)'" + } + else { + $cim_obj = Get-CimInstance -ClassName 'CIM_LogicalFile' -Filter "Name='$(Get-EscapedFileName -FullName $item.FullName)'" + } + if ($state -eq 'present') { + if (-not $module.CheckMode) { + $ret = Invoke-CimMethod -InputObject $cim_obj -MethodName 'CompressEx' -Arguments @{ Recursive = $recurse } + $module.Result.rc = $ret.ReturnValue + } + } + else { + if (-not $module.CheckMode) { + $ret = $ret = Invoke-CimMethod -InputObject $cim_obj -MethodName 'UnCompressEx' -Arguments @{ Recursive = $recurse } + $module.Result.rc = $ret.ReturnValue + } + } +} + +$module.Result.msg = Get-ReturnCodeMessage -code $module.Result.rc +if ($module.Result.rc -ne 0) { + $module.FailJson($module.Result.msg) +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_file_compression.py b/ansible_collections/community/windows/plugins/modules/win_file_compression.py new file mode 100644 index 00000000..265d4bd8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_file_compression.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Micah Hunsberger (@mhunsber) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_file_compression +short_description: Alters the compression of files and directories on NTFS partitions. +description: + - This module sets the compressed attribute for files and directories on a filesystem that supports it like NTFS. + - NTFS compression can be used to save disk space. +options: + path: + description: + - The full path of the file or directory to modify. + - The path must exist on file system that supports compression like NTFS. + required: yes + type: path + state: + description: + - Set to C(present) to ensure the I(path) is compressed. + - Set to C(absent) to ensure the I(path) is not compressed. + type: str + choices: + - absent + - present + default: present + recurse: + description: + - Whether to recursively apply changes to all subdirectories and files. + - This option only has an effect when I(path) is a directory. + - When set to C(false), only applies changes to I(path). + - When set to C(true), applies changes to I(path) and all subdirectories and files. + type: bool + default: false + force: + description: + - This option only has an effect when I(recurse) is C(true) + - If C(true), will check the compressed state of all subdirectories and files + and make a change if any are different from I(compressed). + - If C(false), will only make a change if the compressed state of I(path) is different from I(compressed). + - If the folder structure is complex or contains a lot of files, it is recommended to set this + option to C(false) so that not every file has to be checked. + type: bool + default: true +author: + - Micah Hunsberger (@mhunsber) +notes: + - M(community.windows.win_file_compression) sets the file system's compression state, it does not create a zip + archive file. + - For more about NTFS Compression, see U(http://www.ntfs.com/ntfs-compressed.htm) +''' + +EXAMPLES = r''' +- name: Compress log files directory + community.windows.win_file_compression: + path: C:\Logs + state: present + +- name: Decompress log files directory + community.windows.win_file_compression: + path: C:\Logs + state: absent + +- name: Compress reports directory and all subdirectories + community.windows.win_file_compression: + path: C:\business\reports + state: present + recurse: yes + +# This will only check C:\business\reports for the compressed state +# If C:\business\reports is compressed, it will not make a change +# even if one of the child items is uncompressed + +- name: Compress reports directory and all subdirectories (quick) + community.windows.win_file_compression: + path: C:\business\reports + compressed: yes + recurse: yes + force: no +''' + +RETURN = r''' +rc: + description: + - The return code of the compress/uncompress operation. + - If no changes are made or the operation is successful, rc is 0. + returned: always + sample: 0 + type: int +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_file_version.ps1 b/ansible_collections/community/windows/plugins/modules/win_file_version.ps1 new file mode 100644 index 00000000..8ca94e81 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_file_version.ps1 @@ -0,0 +1,63 @@ +#!powershell + +# Copyright: (c) 2015, Sam Liu <sam.liu@activenetwork.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args $args -supports_check_mode $true + +$result = @{ + win_file_version = @{} + changed = $false +} + +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -resultobj $result + +If (-Not (Test-Path -LiteralPath $path -PathType Leaf)) { + Fail-Json $result "Specified path $path does not exist or is not a file." +} +$ext = [System.IO.Path]::GetExtension($path) +If ( $ext -notin '.exe', '.dll') { + Fail-Json $result "Specified path $path is not a valid file type; must be DLL or EXE." +} + +Try { + $_version_fields = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($path) + $file_version = $_version_fields.FileVersion + If ($null -eq $file_version) { + $file_version = '' + } + $product_version = $_version_fields.ProductVersion + If ($null -eq $product_version) { + $product_version = '' + } + $file_major_part = $_version_fields.FileMajorPart + If ($null -eq $file_major_part) { + $file_major_part = '' + } + $file_minor_part = $_version_fields.FileMinorPart + If ($null -eq $file_minor_part) { + $file_minor_part = '' + } + $file_build_part = $_version_fields.FileBuildPart + If ($null -eq $file_build_part) { + $file_build_part = '' + } + $file_private_part = $_version_fields.FilePrivatePart + If ($null -eq $file_private_part) { + $file_private_part = '' + } +} +Catch { + Fail-Json $result "Error: $_.Exception.Message" +} + +$result.win_file_version.path = $path.toString() +$result.win_file_version.file_version = $file_version.toString() +$result.win_file_version.product_version = $product_version.toString() +$result.win_file_version.file_major_part = $file_major_part.toString() +$result.win_file_version.file_minor_part = $file_minor_part.toString() +$result.win_file_version.file_build_part = $file_build_part.toString() +$result.win_file_version.file_private_part = $file_private_part.toString() +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_file_version.py b/ansible_collections/community/windows/plugins/modules/win_file_version.py new file mode 100644 index 00000000..15fec88d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_file_version.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Sam Liu <sam.liu@activenetwork.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_file_version +short_description: Get DLL or EXE file build version +description: + - Get DLL or EXE file build version. +notes: + - This module will always return no change. +options: + path: + description: + - File to get version. + - Always provide absolute path. + type: path + required: yes +seealso: +- module: ansible.windows.win_file +author: +- Sam Liu (@SamLiu79) +''' + +EXAMPLES = r''' +- name: Get acm instance version + community.windows.win_file_version: + path: C:\Windows\System32\cmd.exe + register: exe_file_version + +- debug: + msg: '{{ exe_file_version }}' +''' + +RETURN = r''' +path: + description: file path + returned: always + type: str + +file_version: + description: File version number.. + returned: no error + type: str + +product_version: + description: The version of the product this file is distributed with. + returned: no error + type: str + +file_major_part: + description: the major part of the version number. + returned: no error + type: str + +file_minor_part: + description: the minor part of the version number of the file. + returned: no error + type: str + +file_build_part: + description: build number of the file. + returned: no error + type: str + +file_private_part: + description: file private part number. + returned: no error + type: str +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_firewall.ps1 b/ansible_collections/community/windows/plugins/modules/win_firewall.ps1 new file mode 100644 index 00000000..9a203986 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_firewall.ps1 @@ -0,0 +1,90 @@ +#!powershell + +# Copyright: (c) 2017, Michael Eaton <meaton@iforium.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" +$firewall_profiles = @('Domain', 'Private', 'Public') + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$profiles = Get-AnsibleParam -obj $params -name "profiles" -type "list" -default @("Domain", "Private", "Public") +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -failifempty $true -validateset 'disabled', 'enabled' +$inbound_action = Get-AnsibleParam -obj $params -name "inbound_action" -type "str" -validateset 'allow', 'block', 'not_configured' +$outbound_action = Get-AnsibleParam -obj $params -name "outbound_action" -type "str" -validateset 'allow', 'block', 'not_configured' + +$result = @{ + changed = $false + profiles = $profiles + state = $state +} + +try { + get-command Get-NetFirewallProfile > $null + get-command Set-NetFirewallProfile > $null +} +catch { + Fail-Json $result "win_firewall requires Get-NetFirewallProfile and Set-NetFirewallProfile Cmdlets." +} + +$FIREWALL_ENABLED = [Microsoft.PowerShell.Cmdletization.GeneratedTypes.NetSecurity.GpoBoolean]::True +$FIREWALL_DISABLED = [Microsoft.PowerShell.Cmdletization.GeneratedTypes.NetSecurity.GpoBoolean]::False + +Try { + + ForEach ($profile in $firewall_profiles) { + $current_profile = Get-NetFirewallProfile -Name $profile + $currentstate = $current_profile.Enabled + $current_inboundaction = $current_profile.DefaultInboundAction + $current_outboundaction = $current_profile.DefaultOutboundAction + $result.$profile = @{ + enabled = ($currentstate -eq $FIREWALL_ENABLED) + considered = ($profiles -contains $profile) + currentstate = $currentstate + } + + if ($profiles -notcontains $profile) { + continue + } + + if ($state -eq 'enabled') { + + if ($currentstate -eq $FIREWALL_DISABLED) { + Set-NetFirewallProfile -name $profile -Enabled true -WhatIf:$check_mode + $result.changed = $true + $result.$profile.enabled = $true + } + if ($null -ne $inbound_action) { + $inbound_action = [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($inbound_action.ToLower()) -replace '_', '' + if ($inbound_action -ne $current_inboundaction) { + Set-NetFirewallProfile -name $profile -DefaultInboundAction $inbound_action -WhatIf:$check_mode + $result.changed = $true + } + } + if ($null -ne $outbound_action) { + $outbound_action = [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($outbound_action.ToLower()) -replace '_', '' + if ($outbound_action -ne $current_outboundaction) { + Set-NetFirewallProfile -name $profile -DefaultOutboundAction $outbound_action -WhatIf:$check_mode + $result.changed = $true + } + } + } + else { + + if ($currentstate -eq $FIREWALL_ENABLED) { + Set-NetFirewallProfile -name $profile -Enabled false -WhatIf:$check_mode + $result.changed = $true + $result.$profile.enabled = $false + } + + } + } +} +Catch { + Fail-Json $result "an error occurred when attempting to change firewall status for profile $profile $($_.Exception.Message)" +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_firewall.py b/ansible_collections/community/windows/plugins/modules/win_firewall.py new file mode 100644 index 00000000..ba9f2864 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_firewall.py @@ -0,0 +1,89 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Michael Eaton <meaton@iforium.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_firewall +short_description: Enable or disable the Windows Firewall +description: +- Enable or Disable Windows Firewall profiles. +requirements: + - This module requires Windows Management Framework 5 or later. +options: + profiles: + description: + - Specify one or more profiles to change. + type: list + elements: str + choices: [ Domain, Private, Public ] + default: [ Domain, Private, Public ] + state: + description: + - Set state of firewall for given profile. + type: str + choices: [ disabled, enabled ] + inbound_action: + description: + - Set to C(allow) or C(block) inbound network traffic in the profile. + - C(not_configured) is valid when configuring a GPO. + type: str + choices: [ allow, block, not_configured ] + version_added: 1.1.0 + outbound_action: + description: + - Set to C(allow) or C(block) inbound network traffic in the profile. + - C(not_configured) is valid when configuring a GPO. + type: str + choices: [ allow, block, not_configured ] + version_added: 1.1.0 +seealso: +- module: community.windows.win_firewall_rule +author: +- Michael Eaton (@michaeldeaton) +''' + +EXAMPLES = r''' +- name: Enable firewall for Domain, Public and Private profiles + community.windows.win_firewall: + state: enabled + profiles: + - Domain + - Private + - Public + tags: enable_firewall + +- name: Disable Domain firewall + community.windows.win_firewall: + state: disabled + profiles: + - Domain + tags: disable_firewall + +- name: Enable firewall for Domain profile and block outbound connections + community.windows.win_firewall: + profiles: Domain + state: enabled + outbound_action: block + tags: block_connection +''' + +RETURN = r''' +enabled: + description: Current firewall status for chosen profile (after any potential change). + returned: always + type: bool + sample: true +profiles: + description: Chosen profile. + returned: always + type: str + sample: Domain +state: + description: Desired state of the given firewall profile(s). + returned: always + type: list + sample: enabled +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_firewall_rule.ps1 b/ansible_collections/community/windows/plugins/modules/win_firewall_rule.ps1 new file mode 100644 index 00000000..bdbf8894 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_firewall_rule.ps1 @@ -0,0 +1,315 @@ +#!powershell + +# Copyright: (c) 2014, Timothy Vandenbrande <timothy.vandenbrande@gmail.com> +# Copyright: (c) 2017, Artem Zinenko <zinenkoartem@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +function ConvertTo-ProtocolType { + param($protocol) + + $protocolNumber = $protocol -as [int] + if ($protocolNumber -is [int]) { + return $protocolNumber + } + + switch -wildcard ($protocol) { + "tcp" { return [System.Net.Sockets.ProtocolType]::Tcp -as [int] } + "udp" { return [System.Net.Sockets.ProtocolType]::Udp -as [int] } + "icmpv4*" { return [System.Net.Sockets.ProtocolType]::Icmp -as [int] } + "icmpv6*" { return [System.Net.Sockets.ProtocolType]::IcmpV6 -as [int] } + default { throw "Unknown protocol '$protocol'." } + } +} + +# See 'Direction' constants here: https://msdn.microsoft.com/en-us/library/windows/desktop/aa364724(v=vs.85).aspx +function ConvertTo-Direction { + param($directionStr) + + switch ($directionStr) { + "in" { return 1 } + "out" { return 2 } + default { throw "Unknown direction '$directionStr'." } + } +} + +# See 'Action' constants here: https://msdn.microsoft.com/en-us/library/windows/desktop/aa364724(v=vs.85).aspx +function ConvertTo-Action { + param($actionStr) + + switch ($actionStr) { + "block" { return 0 } + "allow" { return 1 } + default { throw "Unknown action '$actionStr'." } + } +} + +# Profile enum values: https://msdn.microsoft.com/en-us/library/windows/desktop/aa366303(v=vs.85).aspx +function ConvertTo-Profile { + param($profilesList) + + $profiles = ($profilesList | Select-Object -Unique | ForEach-Object { + switch ($_) { + "domain" { return 1 } + "private" { return 2 } + "public" { return 4 } + default { throw "Unknown profile '$_'." } + } + } | Measure-Object -Sum).Sum + + if ($profiles -eq 7) { return 0x7fffffff } + return $profiles +} + +function ConvertTo-InterfaceType { + param($interfaceTypes) + + return ($interfaceTypes | Select-Object -Unique | ForEach-Object { + switch ($_) { + "wireless" { return "Wireless" } + "lan" { return "Lan" } + "ras" { return "RemoteAccess" } + default { throw "Unknown interface type '$_'." } + } + }) -Join "," +} + +function ConvertTo-EdgeTraversalOption { + param($edgeTraversalOptionsStr) + + switch ($edgeTraversalOptionsStr) { + "yes" { return 1 } + "deferapp" { return 2 } + "deferuser" { return 3 } + default { throw "Unknown edge traversal options '$edgeTraversalOptionsStr'." } + } +} + +function ConvertTo-SecureFlag { + param($secureFlagsStr) + + switch ($secureFlagsStr) { + "authnoencap" { return 1 } + "authenticate" { return 2 } + "authdynenc" { return 3 } + "authenc" { return 4 } + default { throw "Unknown secure flags '$secureFlagsStr'." } + } +} + +$ErrorActionPreference = "Stop" + +$result = @{ + changed = $false +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" +$description = Get-AnsibleParam -obj $params -name "description" -type "str" +$direction = Get-AnsibleParam -obj $params -name "direction" -type "str" -validateset "in", "out" +$action = Get-AnsibleParam -obj $params -name "action" -type "str" -validateset "allow", "block" +$program = Get-AnsibleParam -obj $params -name "program" -type "str" +$group = Get-AnsibleParam -obj $params -name "group" -type "str" +$service = Get-AnsibleParam -obj $params -name "service" -type "str" +$enabled = Get-AnsibleParam -obj $params -name "enabled" -type "bool" -aliases "enable" +$profiles = Get-AnsibleParam -obj $params -name "profiles" -type "list" -aliases "profile" +$localip = Get-AnsibleParam -obj $params -name "localip" -type "str" +$remoteip = Get-AnsibleParam -obj $params -name "remoteip" -type "str" +$localport = Get-AnsibleParam -obj $params -name "localport" -type "str" +$remoteport = Get-AnsibleParam -obj $params -name "remoteport" -type "str" +$protocol = Get-AnsibleParam -obj $params -name "protocol" -type "str" +$interfacetypes = Get-AnsibleParam -obj $params -name "interfacetypes" -type "list" +$edge = Get-AnsibleParam -obj $params -name "edge" -type "str" -validateset "no", "yes", "deferapp", "deferuser" +$security_options = "notrequired", "authnoencap", "authenticate", "authdynenc", "authenc" +$security = Get-AnsibleParam -obj $params -name "security" -type "str" -validateset $security_options +$icmp_type_code = Get-AnsibleParam -obj $params -name "icmp_type_code" -type "list" + +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent" + +if (-not $name -and -not $group) { + Fail-Json -obj $result -message "Either name or group must be specified" +} + +if ($diff_support) { + $result.diff = @{} + $result.diff.prepared = "" +} + +if ($null -ne $icmp_type_code) { + # COM representation is just "<type>:<code>,<type2>:<code>" so we just join our list + $icmp_type_code = $icmp_type_code -join "," +} + +try { + $fw = New-Object -ComObject HNetCfg.FwPolicy2 + + # If name was specified, filter the rules by name, otherwise find all the rules in the group. + $existingRules = $fw.Rules | Where-Object { + if ($name) { + $_.Name -eq $name + } + else { + $_.Grouping -eq $group + } + } + + # INetFwRule interface description: https://msdn.microsoft.com/en-us/library/windows/desktop/aa365344(v=vs.85).aspx + $new_rule = New-Object -ComObject HNetCfg.FWRule + if ($name) { + $new_rule.Name = $name + } + # the default for enabled in module description is "true", but the actual COM object defaults to "false" when created + if ($null -ne $enabled) { $new_rule.Enabled = $enabled } else { $new_rule.Enabled = $true } + if ($null -ne $description) { $new_rule.Description = $description } + if ($null -ne $group) { $new_rule.Grouping = $group } + if ($null -ne $program -and $program -ne "any") { $new_rule.ApplicationName = [System.Environment]::ExpandEnvironmentVariables($program) } + if ($null -ne $service -and $service -ne "any") { $new_rule.ServiceName = $service } + if ($null -ne $protocol -and $protocol -ne "any") { $new_rule.Protocol = ConvertTo-ProtocolType -protocol $protocol } + if ($null -ne $localport -and $localport -ne "any") { $new_rule.LocalPorts = $localport } + if ($null -ne $remoteport -and $remoteport -ne "any") { $new_rule.RemotePorts = $remoteport } + if ($null -ne $localip -and $localip -ne "any") { $new_rule.LocalAddresses = $localip } + if ($null -ne $remoteip -and $remoteip -ne "any") { $new_rule.RemoteAddresses = $remoteip } + if ($null -ne $icmp_type_code -and $icmp_type_code -ne "any") { $new_rule.IcmpTypesAndCodes = $icmp_type_code } + if ($null -ne $direction) { $new_rule.Direction = ConvertTo-Direction -directionStr $direction } + if ($null -ne $action) { $new_rule.Action = ConvertTo-Action -actionStr $action } + # Profiles value cannot be a uint32, but the "all profiles" value (0x7FFFFFFF) will often become a uint32, so must cast to [int] + if ($null -ne $profiles) { $new_rule.Profiles = [int](ConvertTo-Profile -profilesList $profiles) } + if ($null -ne $interfacetypes -and @(Compare-Object -ReferenceObject $interfacetypes -DifferenceObject @("any")).Count -ne 0) { + $new_rule.InterfaceTypes = ConvertTo-InterfaceType -interfaceTypes $interfacetypes + } + if ($null -ne $edge -and $edge -ne "no") { + # EdgeTraversalOptions property exists only from Windows 7/Windows Server 2008 R2 + # https://msdn.microsoft.com/en-us/library/windows/desktop/dd607256(v=vs.85).aspx + if ($new_rule | Get-Member -Name 'EdgeTraversalOptions') { + $new_rule.EdgeTraversalOptions = ConvertTo-EdgeTraversalOption -edgeTraversalOptionsStr $edge + } + } + if ($null -ne $security -and $security -ne "notrequired") { + # SecureFlags property exists only from Windows 8/Windows Server 2012 + # https://msdn.microsoft.com/en-us/library/windows/desktop/hh447465(v=vs.85).aspx + if ($new_rule | Get-Member -Name 'SecureFlags') { + $new_rule.SecureFlags = ConvertTo-SecureFlag -secureFlagsStr $security + } + } + + $fwPropertiesToCompare = @('Description', 'Direction', 'Action', 'ApplicationName', 'Grouping', 'ServiceName', 'Enabled', + 'Profiles', 'LocalAddresses', 'RemoteAddresses', 'LocalPorts', 'RemotePorts', 'Protocol', 'InterfaceTypes', + 'EdgeTraversalOptions', 'SecureFlags', 'IcmpTypesAndCodes') + $userPassedArguments = @($description, $direction, $action, $program, $group, $service, $enabled, $profiles, $localip, + $remoteip, $localport, $remoteport, $protocol, $interfacetypes, $edge, $security, $icmp_type_code) + + if ($state -eq "absent") { + if (-not $existingRules) { + if ($name) { + $result.msg = "Firewall rule '$name' does not exist." + } + else { + $result.msg = "No firewall rules in group '$group' exist." + } + + } + else { + $rules = foreach ($rule in $existingRules) { + $rule.Name # Output name for module msg string. + + if ($diff_support) { + $result.diff.prepared += "-[$($rule.Name)]`n" + foreach ($prop in $fwPropertiesToCompare) { + $result.diff.prepared += "-$($prop)='$($rule.$prop)'`n" + } + } + + if (-not $check_mode) { + $fw.Rules.Remove($rule.Name) + } + $result.changed = $true + } + $result.msg = "Firewall rule(s) '$($rules -join "', '")' removed." + } + } + elseif ($state -eq "present") { + if (-not $existingRules -and $name) { + # name was specified and no rules were found, create the rule + if ($diff_support) { + $result.diff.prepared += "+[$($new_rule.Name)]`n" + foreach ($prop in $fwPropertiesToCompare) { + $result.diff.prepared += "+$($prop)='$($new_rule.$prop)'`n" + } + } + + if (-not $check_mode) { + $fw.Rules.Add($new_rule) + } + $result.changed = $true + $result.msg = "Firewall rule '$name' created." + } + elseif ($existingRules) { + # Either name or group was specified which matched existing rules, check the properties + $changedRules = [System.Collections.Generic.List[String]]@() + $unchangedRules = [System.Collections.Generic.List[String]]@() + + foreach ($existingRule in $existingRules) { + if ($diff_support) { + $result.diff.prepared += "[$($existingRule.Name)]`n" + } + + $changed = $false + for ($i = 0; $i -lt $fwPropertiesToCompare.Length; $i++) { + $prop = $fwPropertiesToCompare[$i] + if ($null -ne $userPassedArguments[$i]) { + # only change values the user passes in task definition + if ($existingRule.$prop -ne $new_rule.$prop) { + $haveSameAddresses = $false + if ($prop -like "*Addresses") { + $existingAddresses = $existingRule.$prop -split ',' + $newAddresses = $new_rule.$prop -split ',' + if (-not (Compare-Object $existingAddresses $newAddresses)) { + $haveSameAddresses = $true + } + } + if (-not $haveSameAddresses) { + if ($diff_support) { + $result.diff.prepared += "-$($prop)='$($existingRule.$prop)'`n" + $result.diff.prepared += "+$($prop)='$($new_rule.$prop)'`n" + } + + if (-not $check_mode) { + # Profiles value cannot be a uint32, but the "all profiles" value (0x7FFFFFFF) will often become a uint32, + # so must cast to [int] to prevent InvalidCastException under PS5+ + If ($prop -eq 'Profiles') { + $existingRule.Profiles = [int] $new_rule.$prop + } + Else { + $existingRule.$prop = $new_rule.$prop + } + } + $changed = $true + } + } + } + } + + if ($changed) { + $result.changed = $true + $changedRules.Add($existingRule.Name) + } + else { + $unchangedRules.Add($existingRule.Name) + } + } + + $result.msg = "Firewall rule(s) changed '$($changedRules -join "', '")' - unchanged '$($unchangedRules -join "', '")'" + } + } +} +catch [Exception] { + $ex = $_ + $result['exception'] = $($ex | Out-String) + Fail-Json $result $ex.Exception.Message +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_firewall_rule.py b/ansible_collections/community/windows/plugins/modules/win_firewall_rule.py new file mode 100644 index 00000000..111d45a1 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_firewall_rule.py @@ -0,0 +1,196 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2014, Timothy Vandenbrande <timothy.vandenbrande@gmail.com> +# Copyright: (c) 2017, Artem Zinenko <zinenkoartem@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_firewall_rule +short_description: Windows firewall automation +description: + - Allows you to create/remove/update firewall rules. +options: + enabled: + description: + - Whether this firewall rule is enabled or disabled. + - Defaults to C(true) when creating a new rule. + type: bool + aliases: [ enable ] + state: + description: + - Should this rule be added or removed. + type: str + choices: [ absent, present ] + default: present + name: + description: + - The rule's display name. + - This is required unless I(group) is specified. + type: str + group: + description: + - The group name for the rule. + - If I(name) is not specified then the module will set the firewall options for all the rules in this group. + type: str + direction: + description: + - Whether this rule is for inbound or outbound traffic. + - Defaults to C(in) when creating a new rule. + type: str + choices: [ in, out ] + action: + description: + - What to do with the items this rule is for. + - Defaults to C(allow) when creating a new rule. + type: str + choices: [ allow, block ] + description: + description: + - Description for the firewall rule. + type: str + localip: + description: + - The local ip address this rule applies to. + - Set to C(any) to apply to all local ip addresses. + - Defaults to C(any) when creating a new rule. + type: str + remoteip: + description: + - The remote ip address/range this rule applies to. + - Set to C(any) to apply to all remote ip addresses. + - Defaults to C(any) when creating a new rule. + type: str + localport: + description: + - The local port this rule applies to. + - Set to C(any) to apply to all local ports. + - Defaults to C(any) when creating a new rule. + - Must have I(protocol) set + type: str + remoteport: + description: + - The remote port this rule applies to. + - Set to C(any) to apply to all remote ports. + - Defaults to C(any) when creating a new rule. + - Must have I(protocol) set + type: str + program: + description: + - The program this rule applies to. + - Set to C(any) to apply to all programs. + - Defaults to C(any) when creating a new rule. + type: str + service: + description: + - The service this rule applies to. + - Set to C(any) to apply to all services. + - Defaults to C(any) when creating a new rule. + type: str + protocol: + description: + - The protocol this rule applies to. + - Set to C(any) to apply to all services. + - Defaults to C(any) when creating a new rule. + type: str + profiles: + description: + - The profile this rule applies to. + - Defaults to C(domain,private,public) when creating a new rule. + type: list + elements: str + aliases: [ profile ] + icmp_type_code: + description: + - The ICMP types and codes for the rule. + - This is only valid when I(protocol) is C(icmpv4) or C(icmpv6). + - Each entry follows the format C(type:code) where C(type) is the type + number and C(code) is the code number for that type or C(*) for all + codes. + - Set the value to just C(*) to apply the rule for all ICMP type codes. + - See U(https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml) + for a list of ICMP types and the codes that apply to them. + type: list + elements: str +notes: +- Multiple firewall rules can share the same I(name), if there are multiple matches then the module will set the user + defined options for each matching rule. +seealso: +- module: community.windows.win_firewall +author: + - Artem Zinenko (@ar7z1) + - Timothy Vandenbrande (@TimothyVandenbrande) +''' + +EXAMPLES = r''' +- name: Firewall rule to allow SMTP on TCP port 25 + community.windows.win_firewall_rule: + name: SMTP + localport: 25 + action: allow + direction: in + protocol: tcp + state: present + enabled: yes + +- name: Firewall rule to allow RDP on TCP port 3389 + community.windows.win_firewall_rule: + name: Remote Desktop + localport: 3389 + action: allow + direction: in + protocol: tcp + profiles: private + state: present + enabled: yes + +- name: Firewall rule to be created for application group + community.windows.win_firewall_rule: + name: SMTP + group: application + localport: 25 + action: allow + direction: in + protocol: tcp + state: present + enabled: yes + +- name: Enable all the Firewall rules in application group + win_firewall_rule: + group: application + enabled: yes + +- name: Firewall rule to allow port range + community.windows.win_firewall_rule: + name: Sample port range + localport: 5000-5010 + action: allow + direction: in + protocol: tcp + state: present + enabled: yes + +- name: Firewall rule to allow ICMP v4 echo (ping) + community.windows.win_firewall_rule: + name: ICMP Allow incoming V4 echo request + enabled: yes + state: present + profiles: private + action: allow + direction: in + protocol: icmpv4 + icmp_type_code: + - '8:*' + +- name: Firewall rule to alloc ICMP v4 on all type codes + community.windows.win_firewall_rule: + name: ICMP Allow incoming V4 echo request + enabled: yes + state: present + profiles: private + action: allow + direction: in + protocol: icmpv4 + icmp_type_code: '*' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_format.ps1 b/ansible_collections/community/windows/plugins/modules/win_format.ps1 new file mode 100644 index 00000000..0746fbe4 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_format.ps1 @@ -0,0 +1,232 @@ +#!powershell + +# Copyright: (c) 2019, Varun Chopra (@chopraaa) <v@chopraaa.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.2 + +Set-StrictMode -Version 2 + +$ErrorActionPreference = "Stop" + +$spec = @{ + options = @{ + drive_letter = @{ type = "str" } + path = @{ type = "str" } + label = @{ type = "str" } + new_label = @{ type = "str" } + file_system = @{ type = "str"; choices = "ntfs", "refs", "exfat", "fat32", "fat" } + allocation_unit_size = @{ type = "int" } + large_frs = @{ type = "bool" } + full = @{ type = "bool"; default = $false } + compress = @{ type = "bool" } + integrity_streams = @{ type = "bool" } + force = @{ type = "bool"; default = $false } + } + mutually_exclusive = @( + , @('drive_letter', 'path', 'label') + ) + required_one_of = @( + , @('drive_letter', 'path', 'label') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$drive_letter = $module.Params.drive_letter +$path = $module.Params.path +$label = $module.Params.label +$new_label = $module.Params.new_label +$file_system = $module.Params.file_system +$allocation_unit_size = $module.Params.allocation_unit_size +$large_frs = $module.Params.large_frs +$full_format = $module.Params.full +$compress_volume = $module.Params.compress +$integrity_streams = $module.Params.integrity_streams +$force_format = $module.Params.force + +# Some pre-checks +if ($null -ne $drive_letter -and $drive_letter -notmatch "^[a-zA-Z]$") { + $module.FailJson("The parameter drive_letter should be a single character A-Z") +} +if ($integrity_streams -eq $true -and $file_system -ne "refs") { + $module.FailJson("Integrity streams can be enabled only on ReFS volumes. You specified: $($file_system)") +} +if ($compress_volume -eq $true) { + if ($file_system -eq "ntfs") { + if ($null -ne $allocation_unit_size -and $allocation_unit_size -gt 4096) { + $module.FailJson("NTFS compression is not supported for allocation unit sizes above 4096") + } + } + else { + $module.FailJson("Compression can be enabled only on NTFS volumes. You specified: $($file_system)") + } +} + +function Get-AnsibleVolume { + param( + $DriveLetter, + $Path, + $Label + ) + + if ($null -ne $DriveLetter) { + try { + $volume = Get-Volume -DriveLetter $DriveLetter + } + catch { + $module.FailJson("There was an error retrieving the volume using drive_letter $($DriveLetter): $($_.Exception.Message)", $_) + } + } + elseif ($null -ne $Path) { + try { + $volume = Get-Volume -Path $Path + } + catch { + $module.FailJson("There was an error retrieving the volume using path $($Path): $($_.Exception.Message)", $_) + } + } + elseif ($null -ne $Label) { + try { + $volume = Get-Volume -FileSystemLabel $Label + } + catch { + $module.FailJson("There was an error retrieving the volume using label $($Label): $($_.Exception.Message)", $_) + } + } + else { + $module.FailJson("Unable to locate volume: drive_letter, path and label were not specified") + } + + return $volume +} + +function Format-AnsibleVolume { + param( + $Path, + $Label, + $FileSystem, + $Full, + $UseLargeFRS, + $Compress, + $SetIntegrityStreams, + $AllocationUnitSize + ) + $parameters = @{ + Path = $Path + Full = $Full + } + if ($null -ne $UseLargeFRS) { + $parameters.Add("UseLargeFRS", $UseLargeFRS) + } + if ($null -ne $SetIntegrityStreams) { + $parameters.Add("SetIntegrityStreams", $SetIntegrityStreams) + } + if ($null -ne $Compress) { + $parameters.Add("Compress", $Compress) + } + if ($null -ne $Label) { + $parameters.Add("NewFileSystemLabel", $Label) + } + if ($null -ne $FileSystem) { + $parameters.Add("FileSystem", $FileSystem) + } + if ($null -ne $AllocationUnitSize) { + $parameters.Add("AllocationUnitSize", $AllocationUnitSize) + } + + Format-Volume @parameters -Confirm:$false | Out-Null + +} + +$ansible_volume = Get-AnsibleVolume -DriveLetter $drive_letter -Path $path -Label $label +$ansible_file_system = $ansible_volume.FileSystem +$ansible_volume_size = $ansible_volume.Size +$ansible_volume_alu = (Get-CimInstance -ClassName Win32_Volume -Filter "DeviceId = '$($ansible_volume.path.replace('\','\\'))'" -Property BlockSize).BlockSize + +$ansible_partition = Get-Partition -Volume $ansible_volume + +if ( + -not $force_format -and + $null -ne $allocation_unit_size -and + $ansible_volume_alu -ne 0 -and + $null -ne $ansible_volume_alu -and + $allocation_unit_size -ne $ansible_volume_alu +) { + $msg = -join @( + "Force format must be specified since target allocation unit size: $($allocation_unit_size) " + "is different from the current allocation unit size of the volume: $($ansible_volume_alu)" + ) + $module.FailJson($msg) +} + +foreach ($access_path in $ansible_partition.AccessPaths) { + if ($access_path -ne $Path) { + if ($null -ne $file_system -and + -not [string]::IsNullOrEmpty($ansible_file_system) -and + $file_system -ne $ansible_file_system) { + if (-not $force_format) { + $no_files_in_volume = (Get-ChildItem -LiteralPath $access_path -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0 + if ($no_files_in_volume) { + $msg = -join @( + "Force format must be specified since target file system: $($file_system) " + "is different from the current file system of the volume: $($ansible_file_system.ToLower())" + ) + $module.FailJson($msg) + } + else { + $module.FailJson("Force format must be specified to format non-pristine volumes") + } + } + } + else { + $pristine = -not $force_format + } + } +} + +if ($force_format) { + if (-not $module.CheckMode) { + $format_params = @{ + Path = $ansible_volume.Path + Full = $full_format + Label = $new_label + FileSystem = $file_system + SetIntegrityStreams = $integrity_streams + UseLargeFRS = $large_frs + Compress = $compress_volume + AllocationUnitSize = $allocation_unit_size + } + Format-AnsibleVolume @format_params + } + $module.Result.changed = $true +} +else { + if ($pristine) { + if ($null -eq $new_label) { + $new_label = $ansible_volume.FileSystemLabel + } + # Conditions for formatting + if ($ansible_volume_size -eq 0 -or + $ansible_volume.FileSystemLabel -ne $new_label) { + if (-not $module.CheckMode) { + $format_params = @{ + Path = $ansible_volume.Path + Full = $full_format + Label = $new_label + FileSystem = $file_system + SetIntegrityStreams = $integrity_streams + UseLargeFRS = $large_frs + Compress = $compress_volume + AllocationUnitSize = $allocation_unit_size + } + Format-AnsibleVolume @format_params + } + $module.Result.changed = $true + } + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_format.py b/ansible_collections/community/windows/plugins/modules/win_format.py new file mode 100644 index 00000000..3ff93de0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_format.py @@ -0,0 +1,98 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Varun Chopra (@chopraaa) <v@chopraaa.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +module: win_format +short_description: Formats an existing volume or a new volume on an existing partition on Windows +description: + - The M(community.windows.win_format) module formats an existing volume or a new volume on an existing partition on Windows +options: + drive_letter: + description: + - Used to specify the drive letter of the volume to be formatted. + type: str + path: + description: + - Used to specify the path to the volume to be formatted. + type: str + label: + description: + - Used to specify the label of the volume to be formatted. + type: str + new_label: + description: + - Used to specify the new file system label of the formatted volume. + type: str + file_system: + description: + - Used to specify the file system to be used when formatting the target volume. + type: str + choices: [ ntfs, refs, exfat, fat32, fat ] + allocation_unit_size: + description: + - Specifies the cluster size to use when formatting the volume. + - If no cluster size is specified when you format a partition, defaults are selected based on + the size of the partition. + - This value must be a multiple of the physical sector size of the disk. + type: int + large_frs: + description: + - Specifies that large File Record System (FRS) should be used. + type: bool + compress: + description: + - Enable compression on the resulting NTFS volume. + - NTFS compression is not supported where I(allocation_unit_size) is more than 4096. + type: bool + integrity_streams: + description: + - Enable integrity streams on the resulting ReFS volume. + type: bool + full: + description: + - A full format writes to every sector of the disk, takes much longer to perform than the + default (quick) format, and is not recommended on storage that is thinly provisioned. + - Specify C(true) for full format. + type: bool + default: no + force: + description: + - Specify if formatting should be forced for volumes that are not created from new partitions + or if the source and target file system are different. + type: bool + default: no +notes: + - Microsoft Windows Server 2012 or Microsoft Windows 8 or newer is required to use this module. To check if your system is compatible, see + U(https://docs.microsoft.com/en-us/windows/desktop/sysinfo/operating-system-version). + - One of three parameters (I(drive_letter), I(path) and I(label)) are mandatory to identify the target + volume but more than one cannot be specified at the same time. + - This module is idempotent if I(force) is not specified and file system labels remain preserved. + - For more information, see U(https://docs.microsoft.com/en-us/previous-versions/windows/desktop/stormgmt/format-msft-volume) +seealso: + - module: community.windows.win_disk_facts + - module: community.windows.win_partition +author: + - Varun Chopra (@chopraaa) <v@chopraaa.com> +''' + +EXAMPLES = r''' +- name: Create a partition with drive letter D and size 5 GiB + community.windows.win_partition: + drive_letter: D + partition_size: 5 GiB + disk_number: 1 + +- name: Full format the newly created partition as NTFS and label it + community.windows.win_format: + drive_letter: D + file_system: NTFS + new_label: Formatted + full: True +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_hosts.ps1 b/ansible_collections/community/windows/plugins/modules/win_hosts.ps1 new file mode 100644 index 00000000..89766d37 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_hosts.ps1 @@ -0,0 +1,268 @@ +#!powershell + +# Copyright: (c) 2018, Micah Hunsberger (@mhunsber) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +$spec = @{ + options = @{ + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + aliases = @{ type = "list"; elements = "str" } + canonical_name = @{ type = "str" } + ip_address = @{ type = "str" } + action = @{ type = "str"; choices = "add", "remove", "set"; default = "set" } + } + required_if = @(, @( "state", "present", @("canonical_name", "ip_address"))) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$state = $module.Params.state +$aliases = $module.Params.aliases +$canonical_name = $module.Params.canonical_name +$ip_address = $module.Params.ip_address +$action = $module.Params.action + +$tmp = [ipaddress]::None +if ($ip_address -and -not [ipaddress]::TryParse($ip_address, [ref]$tmp)) { + $module.FailJson("win_hosts: Argument ip_address needs to be a valid ip address, but was $ip_address") +} +$ip_address_type = $tmp.AddressFamily + +$hosts_file = Get-Item -LiteralPath "$env:SystemRoot\System32\drivers\etc\hosts" + +Function Get-CommentIndex($line) { + $c_index = $line.IndexOf('#') + if ($c_index -lt 0) { + $c_index = $line.Length + } + return $c_index +} + +Function Get-HostEntryPart($line) { + $success = $true + $c_index = Get-CommentIndex -line $line + $pure_line = $line.Substring(0, $c_index).Trim() + $bits = $pure_line -split "\s+" + if ($bits.Length -lt 2) { + return @{ + success = $false + ip_address = "" + ip_type = "" + canonical_name = "" + aliases = @() + } + } + $ip_obj = [ipaddress]::None + if (-not [ipaddress]::TryParse($bits[0], [ref]$ip_obj) ) { + $success = $false + } + $cname = $bits[1] + $als = New-Object string[] ($bits.Length - 2) + [array]::Copy($bits, 2, $als, 0, $als.Length) + return @{ + success = $success + ip_address = $ip_obj.IPAddressToString + ip_type = $ip_obj.AddressFamily + canonical_name = $cname + aliases = $als + } +} + +Function Find-HostName($line, $name) { + $c_idx = Get-CommentIndex -line $line + $re = New-Object regex ("\s+$($name.Replace('.',"\."))(\s|$)", [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + $match = $re.Match($line, 0, $c_idx) + return $match +} + +Function Remove-HostEntry($list, $idx) { + $module.Result.changed = $true + $list.RemoveAt($idx) +} + +Function Add-HostEntry($list, $cname, $aliases, $ip) { + $module.Result.changed = $true + $line = "$ip $cname $($aliases -join ' ')" + $list.Add($line) | Out-Null +} + +Function Remove-HostnamesFromEntry($list, $idx, $aliases) { + $line = $list[$idx] + $line_removed = $false + + foreach ($name in $aliases) { + $match = Find-HostName -line $line -name $name + if ($match.Success) { + $line = $line.Remove($match.Index + 1, $match.Length - 1) + # was this the last alias? (check for space characters after trimming) + if ($line.Substring(0, (Get-CommentIndex -line $line)).Trim() -inotmatch "\s") { + $list.RemoveAt($idx) + $line_removed = $true + # we're done + return @{ + line_removed = $line_removed + } + } + } + } + if ($line -ne $list[$idx]) { + $module.Result.changed = $true + $list[$idx] = $line + } + return @{ + line_removed = $line_removed + } +} + +Function Add-AliasesToEntry($list, $idx, $aliases) { + $line = $list[$idx] + foreach ($name in $aliases) { + $match = Find-HostName -line $line -name $name + if (-not $match.Success) { + # just add the alias before the comment + $line = $line.Insert((Get-CommentIndex -line $line), " $name ") + } + } + if ($line -ne $list[$idx]) { + $module.Result.changed = $true + $list[$idx] = $line + } +} + +$hosts_lines = New-Object System.Collections.ArrayList + +Get-Content -LiteralPath $hosts_file.FullName | ForEach-Object { $hosts_lines.Add($_) } | Out-Null +$module.Diff.before = ($hosts_lines -join "`n") + "`n" + +if ($state -eq 'absent') { + # go through and remove canonical_name and ip + for ($idx = 0; $idx -lt $hosts_lines.Count; $idx++) { + $entry = $hosts_lines[$idx] + # skip comment lines + if (-not $entry.Trim().StartsWith('#')) { + $entry_parts = Get-HostEntryPart -line $entry + if ($entry_parts.success) { + if (-not $ip_address -or $entry_parts.ip_address -eq $ip_address) { + if (-not $canonical_name -or $entry_parts.canonical_name -eq $canonical_name) { + if (Remove-HostEntry -list $hosts_lines -idx $idx) { + # keep index correct if we removed the line + $idx = $idx - 1 + } + } + } + } + } + } +} +if ($state -eq 'present') { + $entry_idx = -1 + $aliases_to_keep = @() + # go through lines, find the entry and determine what to remove based on action + for ($idx = 0; $idx -lt $hosts_lines.Count; $idx++) { + $entry = $hosts_lines[$idx] + # skip comment lines + if (-not $entry.Trim().StartsWith('#')) { + $entry_parts = Get-HostEntryPart -line $entry + if ($entry_parts.success) { + $aliases_to_remove = @() + if ($entry_parts.ip_address -eq $ip_address) { + if ($entry_parts.canonical_name -eq $canonical_name) { + $entry_idx = $idx + + if ($action -eq 'set') { + $aliases_to_remove = $entry_parts.aliases | Where-Object { $aliases -notcontains $_ } + } + elseif ($action -eq 'remove') { + $aliases_to_remove = $aliases + } + } + else { + # this is the right ip_address, but not the cname we were looking for. + # we need to make sure none of aliases or canonical_name exist for this entry + # since the given canonical_name should be an A/AAAA record, + # and aliases should be cname records for the canonical_name. + $aliases_to_remove = $aliases + $canonical_name + } + } + else { + # this is not the ip_address we are looking for + if ($ip_address_type -eq $entry_parts.ip_type) { + if ($entry_parts.canonical_name -eq $canonical_name) { + Remove-HostEntry -list $hosts_lines -idx $idx + $idx = $idx - 1 + if ($action -ne "set") { + # keep old aliases intact + $aliases_to_keep += $entry_parts.aliases | Where-Object { ($aliases + $aliases_to_keep + $canonical_name) -notcontains $_ } + } + } + elseif ($action -eq "remove") { + $aliases_to_remove = $canonical_name + } + elseif ($aliases -contains $entry_parts.canonical_name) { + Remove-HostEntry -list $hosts_lines -idx $idx + $idx = $idx - 1 + if ($action -eq "add") { + # keep old aliases intact + $aliases_to_keep += $entry_parts.aliases | Where-Object { ($aliases + $aliases_to_keep + $canonical_name) -notcontains $_ } + } + } + else { + $aliases_to_remove = $aliases + $canonical_name + } + } + else { + # TODO: Better ipv6 support. There is odd behavior for when an alias can be used for both ipv6 and ipv4 + } + } + + if ($aliases_to_remove) { + if ((Remove-HostnamesFromEntry -list $hosts_lines -idx $idx -aliases $aliases_to_remove).line_removed) { + $idx = $idx - 1 + } + } + } + } + } + + if ($entry_idx -ge 0) { + $aliases_to_add = @() + $entry_parts = Get-HostEntryPart -line $hosts_lines[$entry_idx] + if ($action -eq 'remove') { + $aliases_to_add = $aliases_to_keep | Where-Object { $entry_parts.aliases -notcontains $_ } + } + else { + $aliases_to_add = ($aliases + $aliases_to_keep) | Where-Object { $entry_parts.aliases -notcontains $_ } + } + + if ($aliases_to_add) { + Add-AliasesToEntry -list $hosts_lines -idx $entry_idx -aliases $aliases_to_add + } + } + else { + # add the entry at the end + if ($action -eq 'remove') { + if ($aliases_to_keep) { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name -aliases $aliases_to_keep + } + else { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name + } + } + else { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name -aliases ($aliases + $aliases_to_keep) + } + } +} + +$module.Diff.after = ($hosts_lines -join "`n") + "`n" +if ( $module.Result.changed -and -not $module.CheckMode ) { + Set-Content -LiteralPath $hosts_file.FullName -Value $hosts_lines +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_hosts.py b/ansible_collections/community/windows/plugins/modules/win_hosts.py new file mode 100644 index 00000000..ba583a56 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_hosts.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Micah Hunsberger (@mhunsber) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_hosts +short_description: Manages hosts file entries on Windows. +description: + - Manages hosts file entries on Windows. + - Maps IPv4 or IPv6 addresses to canonical names. + - Adds, removes, or sets cname records for ip and hostname pairs. + - Modifies %windir%\\system32\\drivers\\etc\\hosts. +options: + state: + description: + - Whether the entry should be present or absent. + - If only I(canonical_name) is provided when C(state=absent), then + all hosts entries with the canonical name of I(canonical_name) + will be removed. + - If only I(ip_address) is provided when C(state=absent), then all + hosts entries with the ip address of I(ip_address) will be removed. + - If I(ip_address) and I(canonical_name) are both omitted when + C(state=absent), then all hosts entries will be removed. + choices: + - absent + - present + default: present + type: str + canonical_name: + description: + - A canonical name for the host entry. + - required for C(state=present). + type: str + ip_address: + description: + - The ip address for the host entry. + - Can be either IPv4 (A record) or IPv6 (AAAA record). + - Required for C(state=present). + type: str + aliases: + description: + - A list of additional names (cname records) for the host entry. + - Only applicable when C(state=present). + type: list + elements: str + action: + choices: + - add + - remove + - set + description: + - Controls the behavior of I(aliases). + - Only applicable when C(state=present). + - If C(add), each alias in I(aliases) will be added to the host entry. + - If C(set), each alias in I(aliases) will be added to the host entry, + and other aliases will be removed from the entry. + default: set + type: str +author: + - Micah Hunsberger (@mhunsber) +notes: + - Each canonical name can only be mapped to one IPv4 and one IPv6 address. + If I(canonical_name) is provided with C(state=present) and is found + to be mapped to another IP address that is the same type as, but unique + from I(ip_address), then I(canonical_name) and all I(aliases) will + be removed from the entry and added to an entry with the provided IP address. + - Each alias can only be mapped to one canonical name. If I(aliases) is provided + with C(state=present) and an alias is found to be mapped to another canonical + name, then the alias will be removed from the entry and either added to or removed + from (depending on I(action)) an entry with the provided canonical name. +seealso: + - module: ansible.windows.win_template + - module: ansible.windows.win_file + - module: ansible.windows.win_copy +''' + +EXAMPLES = r''' +- name: Add 127.0.0.1 as an A record for localhost + community.windows.win_hosts: + state: present + canonical_name: localhost + ip_address: 127.0.0.1 + +- name: Add ::1 as an AAAA record for localhost + community.windows.win_hosts: + state: present + canonical_name: localhost + ip_address: '::1' + +- name: Remove 'bar' and 'zed' from the list of aliases for foo (192.168.1.100) + community.windows.win_hosts: + state: present + canonical_name: foo + ip_address: 192.168.1.100 + action: remove + aliases: + - bar + - zed + +- name: Remove hosts entries with canonical name 'bar' + community.windows.win_hosts: + state: absent + canonical_name: bar + +- name: Remove 10.2.0.1 from the list of hosts + community.windows.win_hosts: + state: absent + ip_address: 10.2.0.1 + +- name: Ensure all name resolution is handled by DNS + community.windows.win_hosts: + state: absent +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_hotfix.ps1 b/ansible_collections/community/windows/plugins/modules/win_hotfix.ps1 new file mode 100644 index 00000000..2ef663d2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_hotfix.ps1 @@ -0,0 +1,270 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$hotfix_kb = Get-AnsibleParam -obj $params -name "hotfix_kb" -type "str" +$hotfix_identifier = Get-AnsibleParam -obj $params -name "hotfix_identifier" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "state" -default "present" -validateset "absent", "present" +$source = Get-AnsibleParam -obj $params -name "source" -type "path" + +$result = @{ + changed = $false + reboot_required = $false +} + +if (Get-Module -Name DISM -ListAvailable) { + Import-Module -Name DISM +} +else { + # Server 2008 R2 doesn't have the DISM module installed on the path, check the Windows ADK path + $adk_root = [System.Environment]::ExpandEnvironmentVariables("%PROGRAMFILES(X86)%\Windows Kits\*\Assessment and Deployment Kit\Deployment Tools\amd64\DISM") + if (Test-Path -LiteralPath $adk_root) { + Import-Module -Name (Get-Item -LiteralPath $adk_root).FullName + } + else { + Fail-Json $result "The DISM PS module needs to be installed, this can be done through the windows-adk chocolately package" + } +} + + +Function Expand-MSU($msu) { + $temp_path = [IO.Path]::GetTempPath() + $temp_foldername = [Guid]::NewGuid() + $output_path = Join-Path -Path $temp_path -ChildPath $temp_foldername + New-Item -Path $output_path -ItemType Directory | Out-Null + + $expand_args = @($msu, $output_path, "-F:*") + + try { + &expand.exe $expand_args | Out-NUll + } + catch { + Fail-Json $result "failed to run expand.exe $($expand_args): $($_.Exception.Message)" + } + if ($LASTEXITCODE -ne 0) { + Fail-Json $result "failed to run expand.exe $($expand_args): RC = $LASTEXITCODE" + } + + return $output_path +} + +Function Get-HotfixMetadataFromName($name) { + try { + $dism_package_info = Get-WindowsPackage -Online -PackageName $name + } + catch { + # build a basic stub for a missing result + $dism_package_info = @{ + PackageState = "NotPresent" + Description = "" + PackageName = $name + } + } + + if ($dism_package_info.Description -match "(KB\d*)") { + $hotfix_kb = $Matches[0] + } + else { + $hotfix_kb = "UNKNOWN" + } + + $metadata = @{ + name = $dism_package_info.PackageName + state = $dism_package_info.PackageState + kb = $hotfix_kb + } + + return $metadata +} + +Function Get-HotfixMetadataFromFile($extract_path) { + # MSU contents https://support.microsoft.com/en-us/help/934307/description-of-the-windows-update-standalone-installer-in-windows + $metadata_path = Get-ChildItem -LiteralPath $extract_path | Where-Object { $_.Extension -eq ".xml" } + if ($null -eq $metadata_path) { + Fail-Json $result "failed to get metadata xml inside MSU file, cannot get hotfix metadata required for this task" + } + [xml]$xml = Get-Content -LiteralPath $metadata_path.FullName + + $xml.unattend.servicing.package.source.location | ForEach-Object { + $cab_source_filename = Split-Path -Path $_ -Leaf + $cab_file = Join-Path -Path $extract_path -ChildPath $cab_source_filename + + try { + $dism_package_info = Get-WindowsPackage -Online -PackagePath $cab_file + } + catch { + Fail-Json $result "failed to get DISM package metadata from path $($extract_path): $($_.Exception.Message)" + } + if ($dism_package_info.Applicable -eq $false) { + Fail-Json $result "hotfix package is not applicable for this server" + } + + $package_properties_path = Get-ChildItem -LiteralPath $extract_path | Where-Object { $_.Extension -eq ".txt" } + if ($null -eq $package_properties_path) { + $hotfix_kb = "UNKNOWN" + } + else { + $package_ini = Get-Content -LiteralPath $package_properties_path.FullName + $entry = $package_ini | Where-Object { $_.StartsWith("KB Article Number") } + if ($null -eq $entry) { + $hotfix_kb = "UNKNOWN" + } + else { + $hotfix_kb = ($entry -split '=')[-1] + $hotfix_kb = "KB$($hotfix_kb.Substring(1, $hotfix_kb.Length - 2))" + } + } + + [pscustomobject]@{ + path = $cab_file + name = $dism_package_info.PackageName + state = $dism_package_info.PackageState + kb = $hotfix_kb + } + } +} + +Function Get-HotfixMetadataFromKB($kb) { + # I really hate doing it this way + $packages = Get-WindowsPackage -Online + $identifier = $packages | Where-Object { $_.PackageName -like "*$kb*" } + + if ($null -eq $identifier) { + # still haven't found the KB, need to loop through the results and check the description + foreach ($package in $packages) { + $raw_metadata = Get-HotfixMetadataFromName -name $package.PackageName + if ($raw_metadata.kb -eq $kb) { + $identifier = $raw_metadata + break + } + } + + # if we still haven't found the package then we need to throw an error + if ($null -eq $metadata) { + Fail-Json $result "failed to get DISM package from KB, to continue specify hotfix_identifier instead" + } + } + else { + $metadata = Get-HotfixMetadataFromName -name $identifier.PackageName + } + + return $metadata +} + +if ($state -eq "absent") { + # uninstall hotfix + # this is a pretty poor way of doing this, is there a better way? + + if ($null -ne $hotfix_identifier) { + $hotfix_metadata = Get-HotfixMetadataFromName -name $hotfix_identifier + } + elseif ($null -ne $hotfix_kb) { + $hotfix_install_info = Get-Hotfix -Id $hotfix_kb -ErrorAction SilentlyContinue + if ($null -ne $hotfix_install_info) { + $hotfix_metadata = Get-HotfixMetadataFromKB -kb $hotfix_kb + } + else { + $hotfix_metadata = @{state = "NotPresent" } + } + } + else { + Fail-Json $result "either hotfix_identifier or hotfix_kb needs to be set when state=absent" + } + + # how do we want to deal with the other states? + if ($hotfix_metadata.state -eq "UninstallPending") { + $result.identifier = $hotfix_metadata.name + $result.kb = $hotfix_metadata.kb + $result.reboot_required = $true + } + elseif ($hotfix_metadata.state -eq "Installed") { + $result.identifier = $hotfix_metadata.name + $result.kb = $hotfix_metadata.kb + + if (-not $check_mode) { + try { + $remove_result = Remove-WindowsPackage -Online -PackageName $hotfix_metadata.name -NoRestart + } + catch { + Fail-Json $result "failed to remove package $($hotfix_metadata.name): $($_.Exception.Message)" + } + $result.reboot_required = $remove_Result.RestartNeeded + } + + $result.changed = $true + } +} +else { + if ($null -eq $source) { + Fail-Json $result "source must be set when state=present" + } + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + Fail-Json $result "the path set for source $source does not exist or is not a file" + } + + # while we do extract the file in check mode we need to do so for valid checking + $extract_path = Expand-MSU -msu $source + try { + $hotfix_metadata = Get-HotfixMetadataFromFile -extract_path $extract_path + + # validate the hotfix matches if the hotfix id has been passed in + if ($null -ne $hotfix_identifier) { + if ($hotfix_metadata.name -ne $hotfix_identifier) { + $msg = -join @( + "the hotfix identifier $hotfix_identifier does not match with the source msu identifier $($hotfix_metadata.name), " + "please omit or specify the correct identifier to continue" + ) + Fail-Json $result $msg + } + } + if ($null -ne $hotfix_kb) { + if ($hotfix_metadata.kb -ne $hotfix_kb) { + $msg = -join @( + "the hotfix KB $hotfix_kb does not match with the source msu KB $($hotfix_metadata.kb), " + "please omit or specify the correct KB to continue" + ) + Fail-Json $result $msg + } + } + + $result.identifiers = @($hotfix_metadata.name) + $result.identifier = $result.identifiers[0] + $result.kbs = @($hotfix_metadata.kb) + $result.kb = $result.kbs[0] + + # how do we want to deal with other states + if ($hotfix_metadata.state -eq "InstallPending") { + # return the reboot required flag, should we fail here instead + $result.reboot_required = $true + } + elseif ($hotfix_metadata.state -ne "Installed") { + if (-not $check_mode) { + try { + $install_result = @( + Foreach ($path in $hotfix_metadata.path) { + Add-WindowsPackage -Online -PackagePath $path -NoRestart + } + ) + } + catch { + Fail-Json $result "failed to add windows package from path $($hotfix_metadata.path): $($_.Exception.Message)" + } + $result.reboot_required = [bool]($install_result.RestartNeeded -eq $true) + } + $result.changed = $true + } + } + finally { + Remove-Item -LiteralPath $extract_path -Force -Recurse + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_hotfix.py b/ansible_collections/community/windows/plugins/modules/win_hotfix.py new file mode 100644 index 00000000..41d03fb9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_hotfix.py @@ -0,0 +1,150 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_hotfix +short_description: Install and uninstalls Windows hotfixes +description: +- Install, uninstall a Windows hotfix. +options: + hotfix_identifier: + description: + - The name of the hotfix as shown in DISM, see examples for details. + - This or C(hotfix_kb) MUST be set when C(state=absent). + - If C(state=present) then the hotfix at C(source) will be validated + against this value, if it does not match an error will occur. + - You can get the identifier by running + 'Get-WindowsPackage -Online -PackagePath path-to-cab-in-msu' after + expanding the msu file. + type: str + hotfix_kb: + description: + - The name of the KB the hotfix relates to, see examples for details. + - This or C(hotfix_identifier) MUST be set when C(state=absent). + - If C(state=present) then the hotfix at C(source) will be validated + against this value, if it does not match an error will occur. + - Because DISM uses the identifier as a key and doesn't refer to a KB in + all cases it is recommended to use C(hotfix_identifier) instead. + type: str + state: + description: + - Whether to install or uninstall the hotfix. + - When C(present), C(source) MUST be set. + - When C(absent), C(hotfix_identifier) or C(hotfix_kb) MUST be set. + type: str + default: present + choices: [ absent, present ] + source: + description: + - The path to the downloaded hotfix .msu file. + - This MUST be set if C(state=present) and MUST be a .msu hotfix file. + type: path +notes: +- This must be run on a host that has the DISM powershell module installed and + a Powershell version >= 4. +- This module is installed by default on Windows 8 and Server 2012 and newer. +- You can manually install this module on Windows 7 and Server 2008 R2 by + installing the Windows ADK + U(https://developer.microsoft.com/en-us/windows/hardware/windows-assessment-deployment-kit), + see examples to see how to do it with chocolatey. +- You can download hotfixes from U(https://www.catalog.update.microsoft.com/Home.aspx). +seealso: +- module: ansible.windows.win_package +- module: ansible.windows.win_updates +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Install Windows ADK with DISM for Server 2008 R2 + chocolatey.chocolatey.win_chocolatey: + name: windows-adk + version: 8.100.26866.0 + state: present + install_args: /features OptionId.DeploymentTools + +- name: Install hotfix without validating the KB and Identifier + community.windows.win_hotfix: + source: C:\temp\windows8.1-kb3172729-x64_e8003822a7ef4705cbb65623b72fd3cec73fe222.msu + state: present + register: hotfix_install + +- ansible.windows.win_reboot: + when: hotfix_install.reboot_required + +- name: Install hotfix validating KB + community.windows.win_hotfix: + hotfix_kb: KB3172729 + source: C:\temp\windows8.1-kb3172729-x64_e8003822a7ef4705cbb65623b72fd3cec73fe222.msu + state: present + register: hotfix_install + +- ansible.windows.win_reboot: + when: hotfix_install.reboot_required + +- name: Install hotfix validating Identifier + community.windows.win_hotfix: + hotfix_identifier: Package_for_KB3172729~31bf3856ad364e35~amd64~~6.3.1.0 + source: C:\temp\windows8.1-kb3172729-x64_e8003822a7ef4705cbb65623b72fd3cec73fe222.msu + state: present + register: hotfix_install + +- ansible.windows.win_reboot: + when: hotfix_install.reboot_required + +- name: Uninstall hotfix with Identifier + community.windows.win_hotfix: + hotfix_identifier: Package_for_KB3172729~31bf3856ad364e35~amd64~~6.3.1.0 + state: absent + register: hotfix_uninstall + +- ansible.windows.win_reboot: + when: hotfix_uninstall.reboot_required + +- name: Uninstall hotfix with KB (not recommended) + community.windows.win_hotfix: + hotfix_kb: KB3172729 + state: absent + register: hotfix_uninstall + +- ansible.windows.win_reboot: + when: hotfix_uninstall.reboot_required +''' + +RETURN = r''' +identifier: + description: The DISM identifier for the hotfix. + returned: success + type: str + sample: Package_for_KB3172729~31bf3856ad364e35~amd64~~6.3.1.0 +identifiers: + description: The DISM identifiers for each hotfix in the msu. + returned: success + type: list + elements: str + sample: + - Package_for_KB3172729~31bf3856ad364e35~amd64~~6.3.1.0 + version_added: '1.10.0' +kb: + description: The KB the hotfix relates to. + returned: success + type: str + sample: KB3172729 +kbs: + description: The KB for each hotfix in the msu, + returned: success + type: list + elements: str + sample: + - KB3172729 + version_added: '1.10.0' +reboot_required: + description: Whether a reboot is required for the install or uninstall to + finalise. + returned: success + type: str + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_http_proxy.ps1 b/ansible_collections/community/windows/plugins/modules/win_http_proxy.ps1 new file mode 100644 index 00000000..3b564cae --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_http_proxy.ps1 @@ -0,0 +1,268 @@ +#!powershell + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + bypass = @{ type = "list"; elements = "str"; no_log = $false } + proxy = @{ type = "raw" } + source = @{ type = "str"; choices = @("ie") } + } + mutually_exclusive = @( + @("proxy", "source"), + @("bypass", "source") + ) + required_by = @{ + bypass = @("proxy") + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$proxy = $module.Params.proxy +$bypass = $module.Params.bypass +$source = $module.Params.source + +# Parse the raw value, it should be a Dictionary or String +if ($proxy -is [System.Collections.IDictionary]) { + $valid_keys = [System.Collections.Generic.List`1[String]]@("http", "https", "ftp", "socks") + # Check to make sure we don't have any invalid keys in the dict + $invalid_keys = [System.Collections.Generic.List`1[String]]@() + foreach ($k in $proxy.Keys) { + if ($k -notin $valid_keys) { + $invalid_keys.Add($k) + } + } + + if ($invalid_keys.Count -gt 0) { + $invalid_keys = $invalid_keys | Sort-Object # So our test assertion doesn't fail due to random ordering + $module.FailJson("Invalid keys found in proxy: $($invalid_keys -join ', '). Valid keys are $($valid_keys -join ', ').") + } + + # Build the proxy string in the form 'protocol=host;', the order of valid_keys is also important + $proxy_list = [System.Collections.Generic.List`1[String]]@() + foreach ($k in $valid_keys) { + if ($proxy.ContainsKey($k)) { + $proxy_list.Add("$k=$($proxy.$k)") + } + } + $proxy = $proxy_list -join ";" +} +elseif ($null -ne $proxy) { + $proxy = $proxy.ToString() +} + +if ($bypass) { + if ([System.String]::IsNullOrEmpty($proxy)) { + $module.FailJson("missing parameter(s) required by ''bypass'': proxy") + } + $bypass = $bypass -join ';' +} + +$win_http_invoke = @' +using System; +using System.Runtime.InteropServices; + +namespace Ansible.WinHttpProxy +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class WINHTTP_CURRENT_USER_IE_PROXY_CONFIG : IDisposable + { + public bool fAutoDetect; + public IntPtr lpszAutoConfigUrl; + public IntPtr lpszProxy; + public IntPtr lpszProxyBypass; + + public void Dispose() + { + if (lpszAutoConfigUrl != IntPtr.Zero) + Marshal.FreeHGlobal(lpszAutoConfigUrl); + if (lpszProxy != IntPtr.Zero) + Marshal.FreeHGlobal(lpszProxy); + if (lpszProxyBypass != IntPtr.Zero) + Marshal.FreeHGlobal(lpszProxyBypass); + GC.SuppressFinalize(this); + } + ~WINHTTP_CURRENT_USER_IE_PROXY_CONFIG() { this.Dispose(); } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class WINHTTP_PROXY_INFO : IDisposable + { + public UInt32 dwAccessType; + public IntPtr lpszProxy; + public IntPtr lpszProxyBypass; + + public void Dispose() + { + if (lpszProxy != IntPtr.Zero) + Marshal.FreeHGlobal(lpszProxy); + if (lpszProxyBypass != IntPtr.Zero) + Marshal.FreeHGlobal(lpszProxyBypass); + GC.SuppressFinalize(this); + } + ~WINHTTP_PROXY_INFO() { this.Dispose(); } + } + } + + internal class NativeMethods + { + [DllImport("Winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool WinHttpGetDefaultProxyConfiguration( + [Out] NativeHelpers.WINHTTP_PROXY_INFO pProxyInfo); + + [DllImport("Winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool WinHttpGetIEProxyConfigForCurrentUser( + [Out] NativeHelpers.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG pProxyConfig); + + [DllImport("Winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool WinHttpSetDefaultProxyConfiguration( + NativeHelpers.WINHTTP_PROXY_INFO pProxyInfo); + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class WinINetProxy + { + public bool AutoDetect; + public string AutoConfigUrl; + public string Proxy; + public string ProxyBypass; + } + + public class WinHttpProxy + { + public string Proxy; + public string ProxyBypass; + + public WinHttpProxy() + { + Refresh(); + } + + public void Set() + { + using (NativeHelpers.WINHTTP_PROXY_INFO proxyInfo = new NativeHelpers.WINHTTP_PROXY_INFO()) + { + if (String.IsNullOrEmpty(Proxy)) + proxyInfo.dwAccessType = 1; // WINHTTP_ACCESS_TYPE_NO_PROXY + else + { + proxyInfo.dwAccessType = 3; // WINHTTP_ACCESS_TYPE_NAMED_PROXY + proxyInfo.lpszProxy = Marshal.StringToHGlobalUni(Proxy); + + if (!String.IsNullOrEmpty(ProxyBypass)) + proxyInfo.lpszProxyBypass = Marshal.StringToHGlobalUni(ProxyBypass); + } + + if (!NativeMethods.WinHttpSetDefaultProxyConfiguration(proxyInfo)) + throw new Win32Exception("WinHttpSetDefaultProxyConfiguration() failed"); + } + } + + public void Refresh() + { + using (NativeHelpers.WINHTTP_PROXY_INFO proxyInfo = new NativeHelpers.WINHTTP_PROXY_INFO()) + { + if (!NativeMethods.WinHttpGetDefaultProxyConfiguration(proxyInfo)) + throw new Win32Exception("WinHttpGetDefaultProxyConfiguration() failed"); + + Proxy = Marshal.PtrToStringUni(proxyInfo.lpszProxy); + ProxyBypass = Marshal.PtrToStringUni(proxyInfo.lpszProxyBypass); + } + } + + public static WinINetProxy GetIEProxyConfig() + { + using (NativeHelpers.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG ieProxy = new NativeHelpers.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG()) + { + if (!NativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ieProxy)) + throw new Win32Exception("WinHttpGetIEProxyConfigForCurrentUser() failed"); + + return new WinINetProxy + { + AutoDetect = ieProxy.fAutoDetect, + AutoConfigUrl = Marshal.PtrToStringUni(ieProxy.lpszAutoConfigUrl), + Proxy = Marshal.PtrToStringUni(ieProxy.lpszProxy), + ProxyBypass = Marshal.PtrToStringUni(ieProxy.lpszProxyBypass), + }; + } + } + } +} +'@ +Add-CSharpType -References $win_http_invoke -AnsibleModule $module + +$actual_proxy = New-Object -TypeName Ansible.WinHttpProxy.WinHttpProxy + +$module.Diff.before = @{ + proxy = $actual_proxy.Proxy + bypass = $actual_proxy.ProxyBypass +} + +if ($source -eq "ie") { + # If source=ie we need to get the server and bypass values from the IE configuration + $ie_proxy = [Ansible.WinHttpProxy.WinHttpProxy]::GetIEProxyConfig() + $proxy = $ie_proxy.Proxy + $bypass = $ie_proxy.ProxyBypass +} + +$previous_proxy = $actual_proxy.Proxy +$previous_bypass = $actual_proxy.ProxyBypass + +# Make sure an empty string is converted to $null for easier comparisons +if ([String]::IsNullOrEmpty($proxy)) { + $proxy = $null +} +if ([String]::IsNullOrEmpty($bypass)) { + $bypass = $null +} + +if ($previous_proxy -ne $proxy -or $previous_bypass -ne $bypass) { + $actual_proxy.Proxy = $proxy + $actual_proxy.ProxyBypass = $bypass + + if (-not $module.CheckMode) { + $actual_proxy.Set() + + # Validate that the change was made correctly and revert if it wasn't. The Set() method won't fail on invalid + # values so we need to check again to make sure all was good. + $actual_proxy.Refresh() + if ($actual_proxy.Proxy -ne $proxy -or $actual_proxy.ProxyBypass -ne $bypass) { + $actual_proxy.Proxy = $previous_proxy + $actual_proxy.ProxyBypass = $previous_bypass + $actual_proxy.Set() + + $module.FailJson("Unknown error when trying to set proxy '$proxy' or bypass '$bypass'") + } + } + + $module.Result.changed = $true +} + +$module.Diff.after = @{ + proxy = $proxy + bypass = $bypass +} + +$module.ExitJson() + diff --git a/ansible_collections/community/windows/plugins/modules/win_http_proxy.py b/ansible_collections/community/windows/plugins/modules/win_http_proxy.py new file mode 100644 index 00000000..92f7e5c6 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_http_proxy.py @@ -0,0 +1,100 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_http_proxy +short_description: Manages proxy settings for WinHTTP +description: +- Used to set, remove, or import proxy settings for Windows HTTP Services + C(WinHTTP). +- WinHTTP is a framework used by applications or services, typically .NET + applications or non-interactive services, to make web requests. +options: + bypass: + description: + - A list of hosts that will bypass the set proxy when being accessed. + - Use C(<local>) to match hostnames that are not fully qualified domain + names. This is useful when needing to connect to intranet sites using + just the hostname. + - Omit, set to null or an empty string/list to remove the bypass list. + - If this is set then I(proxy) must also be set. + type: list + elements: str + proxy: + description: + - A string or dict that specifies the proxy to be set. + - If setting a string, should be in the form C(hostname), C(hostname:port), + or C(protocol=hostname:port). + - If the port is undefined, the default port for the protocol in use is + used. + - If setting a dict, the keys should be the protocol and the values should + be the hostname and/or port for that protocol. + - Valid protocols are C(http), C(https), C(ftp), and C(socks). + - Omit, set to null or an empty string to remove the proxy settings. + type: raw + source: + description: + - Instead of manually specifying the I(proxy) and/or I(bypass), set this to + import the proxy from a set source like Internet Explorer. + - Using C(ie) will import the Internet Explorer proxy settings for the + current active network connection of the current user. + - Only IE's proxy URL and bypass list will be imported into WinHTTP. + - This is like running C(netsh winhttp import proxy source=ie). + - The value is imported when the module runs and will not automatically + be updated if the IE configuration changes in the future. The module will + have to be run again to sync the latest changes. + choices: + - ie + type: str +notes: +- This is not the same as the proxy settings set in Internet Explorer, also + known as C(WinINet); use the M(community.windows.win_inet_proxy) module to manage that instead. +- These settings are set system wide and not per user, it will require + Administrative privileges to run. +seealso: +- module: community.windows.win_inet_proxy +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Set a proxy to use for all protocols + community.windows.win_http_proxy: + proxy: hostname + +- name: Set a proxy with a specific port with a bypass list + community.windows.win_http_proxy: + proxy: hostname:8080 + bypass: + - server1 + - server2 + - <local> + +- name: Set the proxy based on the IE proxy settings + community.windows.win_http_proxy: + source: ie + +- name: Set a proxy for specific protocols + community.windows.win_http_proxy: + proxy: + http: hostname:8080 + https: hostname:8443 + +- name: Set a proxy for specific protocols using a string + community.windows.win_http_proxy: + proxy: http=hostname:8080;https=hostname:8443 + bypass: server1,server2,<local> + +- name: Remove any proxy settings + community.windows.win_http_proxy: + proxy: '' + bypass: '' +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.ps1 b/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.ps1 new file mode 100644 index 00000000..d2c3ba07 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.ps1 @@ -0,0 +1,139 @@ +#!powershell + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +$params = Parse-Args $args +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$site = Get-AnsibleParam -obj $params -name "site" -type "str" -failifempty $true +$application = Get-AnsibleParam -obj $params -name "application" -type "str" +$physical_path = Get-AnsibleParam -obj $params -name "physical_path" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present" +$connect_as = Get-AnsibleParam -obj $params -name 'connect_as' -type 'str' -validateset 'specific_user', 'pass_through' +$username = Get-AnsibleParam -obj $params -name "username" -type "str" -failifempty ($connect_as -eq 'specific_user') +$password = Get-AnsibleParam -obj $params -name "password" -type "str" -failifempty ($connect_as -eq 'specific_user') + +# Ensure WebAdministration module is loaded +if ($null -eq (Get-Module "WebAdministration" -ErrorAction SilentlyContinue)) { + Import-Module WebAdministration +} + +# Result +$result = @{ + directory = @{} + changed = $false +} + +# Construct path +$directory_path = if ($application) { + "IIS:\Sites\$($site)\$($application)\$($name)" +} +else { + "IIS:\Sites\$($site)\$($name)" +} + +# Directory info +$directory = if ($application) { + Get-WebVirtualDirectory -Site $site -Name $name -Application $application +} +else { + Get-WebVirtualDirectory -Site $site -Name $name +} + +try { + # Add directory + If (($state -eq 'present') -and (-not $directory)) { + If (-not $physical_path) { + Fail-Json -obj $result -message "missing required arguments: physical_path" + } + If (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json -obj $result -message "specified folder must already exist: physical_path" + } + + $directory_parameters = @{ + Site = $site + Name = $name + PhysicalPath = $physical_path + } + + If ($application) { + $directory_parameters.Application = $application + } + + $directory = New-WebVirtualDirectory @directory_parameters -Force + $result.changed = $true + } + + # Remove directory + If ($state -eq 'absent' -and $directory) { + Remove-Item -LiteralPath $directory_path -Recurse -Force + $result.changed = $true + } + + $directory = if ($application) { + Get-WebVirtualDirectory -Site $site -Name $name -Application $application + } + else { + Get-WebVirtualDirectory -Site $site -Name $name + } + + If ($directory) { + + # Change Physical Path if needed + if ($physical_path) { + If (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json -obj $result -message "specified folder must already exist: physical_path" + } + + $vdir_folder = Get-Item -LiteralPath $directory.PhysicalPath + $folder = Get-Item -LiteralPath $physical_path + If ($folder.FullName -ne $vdir_folder.FullName) { + Set-ItemProperty -LiteralPath $directory_path -name physicalPath -value $physical_path + $result.changed = $true + } + } + + # Change username or password if needed + if ($connect_as -eq 'pass_through') { + if ($directory.username -ne '') { + Clear-ItemProperty -LiteralPath $directory_path -Name 'userName' + $result.changed = $true + } + if ($directory.password -ne '') { + Clear-ItemProperty -LiteralPath $directory_path -Name 'password' + $result.changed = $true + } + } + elseif ($connect_as -eq 'specific_user') { + if ($directory.username -ne $username) { + Set-ItemProperty -LiteralPath $directory_path -Name 'userName' -Value $username + $result.changed = $true + } + if ($directory.password -ne $password) { + Set-ItemProperty -LiteralPath $directory_path -Name 'password' -Value $password + $result.changed = $true + } + } + } +} +catch { + Fail-Json $result $_.Exception.Message +} + +# Result +$directory = if ($application) { + Get-WebVirtualDirectory -Site $site -Name $name -Application $application +} +else { + Get-WebVirtualDirectory -Site $site -Name $name +} + +$result.directory = @{ + PhysicalPath = $directory.PhysicalPath +} + +Exit-Json -obj $result
\ No newline at end of file diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.py b/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.py new file mode 100644 index 00000000..5d572f8f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_virtualdirectory.py @@ -0,0 +1,90 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_iis_virtualdirectory +short_description: Configures a virtual directory in IIS +description: + - Creates, Removes and configures a virtual directory in IIS. +options: + name: + description: + - The name of the virtual directory to create or remove. + type: str + required: yes + state: + description: + - Whether to add or remove the specified virtual directory. + - Removing will remove the virtual directory and all under it (Recursively). + type: str + choices: [ absent, present ] + default: present + site: + description: + - The site name under which the virtual directory is created or exists. + type: str + required: yes + application: + description: + - The application under which the virtual directory is created or exists. + type: str + physical_path: + description: + - The physical path to the folder in which the new virtual directory is created. + - The specified folder must already exist. + type: str + connect_as: + description: + - The type of authentication to use for the virtual directory. Either C(pass_through) or C(specific_user) + - If C(pass_through), IIS will use the identity of the user or application pool identity to access the physical path. + - If C(specific_user), IIS will use the credentials provided in I(username) and I(password) to access the physical path. + type: str + choices: [pass_through, specific_user] + version_added: 1.9.0 + username: + description: + - Specifies the user name of an account that can access configuration files and content for the virtual directory. + - Required when I(connect_as) is set to C(specific_user). + type: str + version_added: 1.9.0 + password: + description: + - The password associated with I(username). + - Required when I(connect_as) is set to C(specific_user). + type: str + version_added: 1.9.0 +seealso: +- module: community.windows.win_iis_webapplication +- module: community.windows.win_iis_webapppool +- module: community.windows.win_iis_webbinding +- module: community.windows.win_iis_website +author: +- Henrik Wallström (@henrikwallstrom) +''' + +EXAMPLES = r''' +- name: Create a virtual directory if it does not exist + community.windows.win_iis_virtualdirectory: + name: somedirectory + site: somesite + state: present + physical_path: C:\virtualdirectory\some + +- name: Remove a virtual directory if it exists + community.windows.win_iis_virtualdirectory: + name: somedirectory + site: somesite + state: absent + +- name: Create a virtual directory on an application if it does not exist + community.windows.win_iis_virtualdirectory: + name: somedirectory + site: somesite + application: someapp + state: present + physical_path: C:\virtualdirectory\some +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.ps1 b/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.ps1 new file mode 100644 index 00000000..86fa850c --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.ps1 @@ -0,0 +1,140 @@ +#!powershell + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$site = Get-AnsibleParam -obj $params -name "site" -type "str" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present" +$physical_path = Get-AnsibleParam -obj $params -name "physical_path" -type "str" -aliases "path" +$application_pool = Get-AnsibleParam -obj $params -name "application_pool" -type "str" +$connect_as = Get-AnsibleParam -obj $params -name 'connect_as' -type 'str' -validateset 'specific_user', 'pass_through' +$username = Get-AnsibleParam -obj $params -name "username" -type "str" -failifempty ($connect_as -eq 'specific_user') +$password = Get-AnsibleParam -obj $params -name "password" -type "str" -failifempty ($connect_as -eq 'specific_user') + +$result = @{ + application_pool = $application_pool + changed = $false + physical_path = $physical_path +} + +# Ensure WebAdministration module is loaded +if ($null -eq (Get-Module "WebAdministration" -ErrorAction SilentlyContinue)) { + Import-Module WebAdministration +} + +# Application info +$application = Get-WebApplication -Site $site -Name $name +$website = Get-Website -Name $site + +# Set ApplicationPool to current if not specified +if (!$application_pool) { + $application_pool = $website.applicationPool +} + +try { + # Add application + if (($state -eq 'present') -and (-not $application)) { + if (-not $physical_path) { + Fail-Json $result "missing required arguments: path" + } + if (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json $result "specified folder must already exist: path" + } + + $application_parameters = @{ + Name = $name + PhysicalPath = $physical_path + Site = $site + } + + if ($application_pool) { + $application_parameters.ApplicationPool = $application_pool + } + + if (-not $check_mode) { + $application = New-WebApplication @application_parameters -Force + } + $result.changed = $true + } + + # Remove application + if ($state -eq 'absent' -and $application) { + $application = Remove-WebApplication -Site $site -Name $name -WhatIf:$check_mode + $result.changed = $true + } + + $application = Get-WebApplication -Site $site -Name $name + if ($application) { + + # Change Physical Path if needed + if ($physical_path) { + if (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json $result "specified folder must already exist: path" + } + + $folder = Get-Item -LiteralPath $physical_path + if ($folder.FullName -ne $application.PhysicalPath) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -name physicalPath -value $physical_path -WhatIf:$check_mode + $result.changed = $true + } + } + + # Change Application Pool if needed + if ($application_pool) { + if ($application_pool -ne $application.applicationPool) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -name applicationPool -value $application_pool -WhatIf:$check_mode + $result.changed = $true + } + } + + # Change username and password if needed + $app_user = Get-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'userName' + $app_pass = Get-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'password' + if ($connect_as -eq 'pass_through') { + if ($app_user -ne '') { + Clear-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'userName' -WhatIf:$check_mode + $result.changed = $true + } + if ($app_pass -ne '') { + Clear-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'password' -WhatIf:$check_mode + $result.changed = $true + } + } + elseif ($connect_as -eq 'specific_user') { + if ($app_user -ne $username) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'userName' -Value $username -WhatIf:$check_mode + $result.changed = $true + } + if ($app_pass -ne $password) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'password' -Value $password -WhatIf:$check_mode + $result.changed = $true + } + } + } +} +catch { + Fail-Json $result $_.Exception.Message +} + +# When in check-mode or on removal, this may fail +$application = Get-WebApplication -Site $site -Name $name +if ($application) { + $app_user = Get-ItemProperty -LiteralPath "IIS:\Sites\$($site)\$($name)" -Name 'userName' + if ($app_user -eq '') { + $result.connect_as = 'pass_through' + } + else { + $result.connect_as = 'specific_user' + } + + $result.physical_path = $application.PhysicalPath + $result.application_pool = $application.ApplicationPool +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.py b/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.py new file mode 100644 index 00000000..50149da7 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webapplication.py @@ -0,0 +1,91 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_iis_webapplication +short_description: Configures IIS web applications +description: +- Creates, removes, and configures IIS web applications. +options: + name: + description: + - Name of the web application. + type: str + required: yes + site: + description: + - Name of the site on which the application is created. + type: str + required: yes + state: + description: + - State of the web application. + type: str + choices: [ absent, present ] + default: present + physical_path: + description: + - The physical path on the remote host to use for the new application. + - The specified folder must already exist. + type: str + application_pool: + description: + - The application pool in which the new site executes. + - If not specified, the application pool of the current website will be used. + type: str + connect_as: + description: + - The type of authentication to use for this application. Either C(pass_through) or C(specific_user) + - If C(pass_through), IIS will use the identity of the user or application pool identity to access the file system or network. + - If C(specific_user), IIS will use the credentials provided in I(username) and I(password) to access the file system or network. + type: str + choices: [pass_through, specific_user] + username: + description: + - Specifies the user name of an account that can access configuration files and content for this application. + - Required when I(connect_as) is set to C(specific_user). + type: str + password: + description: + - The password associated with I(username). + - Required when I(connect_as) is set to C(specific_user). + type: str +seealso: +- module: community.windows.win_iis_virtualdirectory +- module: community.windows.win_iis_webapppool +- module: community.windows.win_iis_webbinding +- module: community.windows.win_iis_website +author: +- Henrik Wallström (@henrikwallstrom) +''' + +EXAMPLES = r''' +- name: Add ACME webapplication on IIS. + community.windows.win_iis_webapplication: + name: api + site: acme + state: present + physical_path: C:\apps\acme\api +''' + +RETURN = r''' +application_pool: + description: The used/implemented application_pool value. + returned: success + type: str + sample: DefaultAppPool +physical_path: + description: The used/implemented physical_path value. + returned: success + type: str + sample: C:\apps\acme\api +connect_as: + description: How IIS will try to authenticate to the physical_path. + returned: when the application exists + type: str + sample: specific_user +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.ps1 b/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.ps1 new file mode 100644 index 00000000..28b339b4 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.ps1 @@ -0,0 +1,341 @@ +#!powershell + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateSet "started", "restarted", "stopped", "absent", "present" +$result = @{ + changed = $false + attributes = @{} + info = @{ + name = $name + state = $state + attributes = @{} + cpu = @{} + failure = @{} + processModel = @{} + recycling = @{ + periodicRestart = @{} + } + } +} + +# Stores the free form attributes for the module +$attributes = @{} +$input_attributes = Get-AnsibleParam -obj $params -name "attributes" +if ($input_attributes) { + if ($input_attributes -is [System.Collections.Hashtable]) { + # Uses dict style parameters, newer and recommended style + $attributes = $input_attributes + } + else { + Fail-Json -obj $result -message "Using a string for the attributes parameter is not longer supported, please use a dict instead" + } +} +$result.attributes = $attributes + +Function Get-DotNetClassForAttribute($attribute_parent) { + switch ($attribute_parent) { + "attributes" { [Microsoft.Web.Administration.ApplicationPool] } + "cpu" { [Microsoft.Web.Administration.ApplicationPoolCpu] } + "failure" { [Microsoft.Web.Administration.ApplicationPoolFailure] } + "processModel" { [Microsoft.Web.Administration.ApplicationPoolProcessModel] } + "recycling" { [Microsoft.Web.Administration.ApplicationPoolRecycling] } + default { [Microsoft.Web.Administration.ApplicationPool] } + } +} + +Function Convert-CollectionToList($collection) { + $list = @() + + if ($collection -is [String]) { + $raw_list = $collection -split "," + foreach ($entry in $raw_list) { + $list += $entry.Trim() + } + } + elseif ($collection -is [Microsoft.IIs.PowerShell.Framework.ConfigurationElement]) { + # the collection is the value from IIS itself, we need to conver accordingly + foreach ($entry in $collection.Collection) { + $list += $entry.Value.ToString() + } + } + elseif ($collection -isnot [Array]) { + $list += $collection + } + else { + $list = $collection + } + + return , $list +} + +Function Compare-Value($current, $new) { + if ($null -eq $current) { + return $true + } + + if ($current -is [Array]) { + if ($new -isnot [Array]) { + return $true + } + + if ($current.Count -ne $new.Count) { + return $true + } + for ($i = 0; $i -lt $current.Count; $i++) { + if ($current[$i] -ne $new[$i]) { + return $true + } + } + } + else { + if ($current -ne $new) { + return $true + } + } + return $false +} + +Function Convert-ToPropertyValue($pool, $attribute_key, $attribute_value) { + # Will convert the new value to the enum value expected and cast accordingly to the type + if ([bool]($attribute_value.PSobject.Properties -match "Value")) { + $attribute_value = $attribute_value.Value + } + $attribute_key_split = $attribute_key -split "\." + if ($attribute_key_split.Length -eq 1) { + $attribute_parent = "attributes" + $attribute_child = $attribute_key + $attribute_meta = $pool.Attributes | Where-Object { $_.Name -eq $attribute_child } + } + elseif ($attribute_key_split.Length -gt 1) { + $attribute_parent = $attribute_key_split[0] + $attribute_key_split = $attribute_key_split[1..$($attribute_key_split.Length - 1)] + $parent = $pool.$attribute_parent + + foreach ($key in $attribute_key_split) { + $attribute_meta = $parent.Attributes | Where-Object { $_.Name -eq $key } + $parent = $parent.$key + if ($null -eq $attribute_meta) { + $attribute_meta = $parent + } + } + $attribute_child = $attribute_key_split[-1] + } + + if ($attribute_meta) { + if (($attribute_meta.PSObject.Properties.Name -eq "Collection").Count -gt 0) { + return , (Convert-CollectionToList -collection $attribute_value) + } + $type = $attribute_meta.Schema.Type + $value = $attribute_value + if ($type -eq "enum") { + # Attempt to convert the value from human friendly to enum value - use existing value if we fail + $dot_net_class = Get-DotNetClassForAttribute -attribute_parent $attribute_parent + $enum_attribute_name = $attribute_child.Substring(0, 1).ToUpper() + $attribute_child.Substring(1) + $enum = $dot_net_class.GetProperty($enum_attribute_name).PropertyType.FullName + if ($enum) { + $enum_values = [Enum]::GetValues($enum) + foreach ($enum_value in $enum_values) { + if ($attribute_value.GetType() -is $enum_value.GetType()) { + if ($enum_value -eq $attribute_value) { + $value = $enum_value + break + } + } + else { + if ([System.String]$enum_value -eq [System.String]$attribute_value) { + $value = $enum_value + break + } + } + } + } + } + # Try and cast the variable using the chosen type, revert to the default if it fails + Set-Variable -Name casted_value -Value ($value -as ([type] $attribute_meta.TypeName)) + if ($null -eq $casted_value) { + $value + } + else { + $casted_value + } + } + else { + $attribute_value + } +} + +# Ensure WebAdministration module is loaded +if ($null -eq (Get-Module -Name "WebAdministration" -ErrorAction SilentlyContinue)) { + Import-Module WebAdministration + $web_admin_dll_path = Join-Path $env:SystemRoot system32\inetsrv\Microsoft.Web.Administration.dll + Add-Type -LiteralPath $web_admin_dll_path +} + +$pool = Get-Item -LiteralPath IIS:\AppPools\$name -ErrorAction SilentlyContinue +if ($state -eq "absent") { + # Remove pool if present + if ($pool) { + try { + Remove-WebAppPool -Name $name -WhatIf:$check_mode + } + catch { + Fail-Json $result "Failed to remove Web App pool $($name): $($_.Exception.Message)" + } + $result.changed = $true + } +} +else { + # Add pool if absent + if (-not $pool) { + if (-not $check_mode) { + try { + New-WebAppPool -Name $name > $null + } + catch { + Fail-Json $result "Failed to create new Web App Pool $($name): $($_.Exception.Message)" + } + } + $result.changed = $true + # If in check mode this pool won't actually exists so skip it + if (-not $check_mode) { + $pool = Get-Item -LiteralPath IIS:\AppPools\$name + } + } + + # Cannot run the below in check mode if the pool did not always exist + if ($pool) { + # Modify pool based on parameters + foreach ($attribute in $attributes.GetEnumerator()) { + $attribute_key = $attribute.Name + $new_raw_value = $attribute.Value + $new_value = Convert-ToPropertyValue -pool $pool -attribute_key $attribute_key -attribute_value $new_raw_value + + $current_raw_value = Get-ItemProperty -LiteralPath IIS:\AppPools\$name -Name $attribute_key -ErrorAction SilentlyContinue + $current_value = Convert-ToPropertyValue -pool $pool -attribute_key $attribute_key -attribute_value $current_raw_value + + $changed = Compare-Value -current $current_value -new $new_value + if ($changed -eq $true) { + if ($new_value -is [Array]) { + try { + Clear-ItemProperty -LiteralPath IIS:\AppPools\$name -Name $attribute_key -WhatIf:$check_mode + } + catch { + $msg = -join @( + "Failed to clear attribute to Web App Pool $name. Attribute: $attribute_key, " + "Exception: $($_.Exception.Message)" + ) + Fail-Json -obj $result -message $msg + } + foreach ($value in $new_value) { + try { + New-ItemProperty -LiteralPath IIS:\AppPools\$name -Name $attribute_key -Value @{value = $value } -WhatIf:$check_mode > $null + } + catch { + $msg = -join @( + "Failed to add new attribute to Web App Pool $name. Attribute: $attribute_key, " + "Value: $value, Exception: $($_.Exception.Message)" + ) + Fail-Json -obj $result -message $msg + } + } + } + else { + try { + Set-ItemProperty -LiteralPath IIS:\AppPools\$name -Name $attribute_key -Value $new_value -WhatIf:$check_mode + } + catch { + $msg = -join @( + "Failed to set attribute to Web App Pool $name. Attribute: $attribute_key, " + "Value: $new_value, Exception: $($_.Exception.Message)" + ) + Fail-Json $result $msg + } + } + $result.changed = $true + } + } + + # Set the state of the pool + if ($pool.State -eq "Stopped") { + if ($state -eq "started" -or $state -eq "restarted") { + if (-not $check_mode) { + try { + Start-WebAppPool -Name $name > $null + } + catch { + Fail-Json $result "Failed to start Web App Pool $($name): $($_.Exception.Message)" + } + } + $result.changed = $true + } + } + else { + if ($state -eq "stopped") { + if (-not $check_mode) { + try { + Stop-WebAppPool -Name $name > $null + } + catch { + Fail-Json $result "Failed to stop Web App Pool $($name): $($_.Exception.Message)" + } + } + $result.changed = $true + } + elseif ($state -eq "restarted") { + if (-not $check_mode) { + try { + Restart-WebAppPool -Name $name > $null + } + catch { + Fail-Json $result "Failed to restart Web App Pool $($name): $($_.Exception.Message)" + } + } + $result.changed = $true + } + } + } +} + +# Get all the current attributes for the pool +$pool = Get-Item -LiteralPath IIS:\AppPools\$name -ErrorAction SilentlyContinue +$elements = @("attributes", "cpu", "failure", "processModel", "recycling") + +foreach ($element in $elements) { + if ($element -eq "attributes") { + $attribute_collection = $pool.Attributes + $attribute_parent = $pool + } + else { + $attribute_collection = $pool.$element.Attributes + $attribute_parent = $pool.$element + } + + foreach ($attribute in $attribute_collection) { + $attribute_name = $attribute.Name + if ($attribute_name -notlike "*password*") { + $attribute_value = $attribute_parent.$attribute_name + + $result.info.$element.Add($attribute_name, $attribute_value) + } + } +} + +# Manually get the periodicRestart attributes in recycling +foreach ($attribute in $pool.recycling.periodicRestart.Attributes) { + $attribute_name = $attribute.Name + $attribute_value = $pool.recycling.periodicRestart.$attribute_name + $result.info.recycling.periodicRestart.Add($attribute_name, $attribute_value) +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.py b/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.py new file mode 100644 index 00000000..7cca2e3b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webapppool.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_iis_webapppool +short_description: Configure IIS Web Application Pools +description: + - Creates, removes and configures an IIS Web Application Pool. +options: + attributes: + description: + - This field is a free form dictionary value for the application pool + attributes. + - These attributes are based on the naming standard at + U(https://www.iis.net/configreference/system.applicationhost/applicationpools/add#005), + see the examples section for more details on how to set this. + - You can also set the attributes of child elements like cpu and + processModel, see the examples to see how it is done. + - While you can use the numeric values for enums it is recommended to use + the enum name itself, e.g. use SpecificUser instead of 3 for + processModel.identityType. + - managedPipelineMode may be either "Integrated" or "Classic". + - startMode may be either "OnDemand" or "AlwaysRunning". + - Use C(state) module parameter to modify the state of the app pool. + - When trying to set 'processModel.password' and you receive a 'Value + does fall within the expected range' error, you have a corrupted + keystore. Please follow + U(http://structuredsight.com/2014/10/26/im-out-of-range-youre-out-of-range/) + to help fix your host. + name: + description: + - Name of the application pool. + type: str + required: yes + state: + description: + - The state of the application pool. + - If C(absent) will ensure the app pool is removed. + - If C(present) will ensure the app pool is configured and exists. + - If C(restarted) will ensure the app pool exists and will restart, this + is never idempotent. + - If C(started) will ensure the app pool exists and is started. + - If C(stopped) will ensure the app pool exists and is stopped. + type: str + choices: [ absent, present, restarted, started, stopped ] + default: present +seealso: +- module: community.windows.win_iis_virtualdirectory +- module: community.windows.win_iis_webapplication +- module: community.windows.win_iis_webbinding +- module: community.windows.win_iis_website +author: +- Henrik Wallström (@henrikwallstrom) +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Return information about an existing application pool + community.windows.win_iis_webapppool: + name: DefaultAppPool + state: present + +- name: Create a new application pool in 'Started' state + community.windows.win_iis_webapppool: + name: AppPool + state: started + +- name: Stop an application pool + community.windows.win_iis_webapppool: + name: AppPool + state: stopped + +- name: Restart an application pool (non-idempotent) + community.windows.win_iis_webapppool: + name: AppPool + state: restarted + +- name: Change application pool attributes using new dict style + community.windows.win_iis_webapppool: + name: AppPool + attributes: + managedRuntimeVersion: v4.0 + autoStart: no + +- name: Creates an application pool, sets attributes and starts it + community.windows.win_iis_webapppool: + name: AnotherAppPool + state: started + attributes: + managedRuntimeVersion: v4.0 + autoStart: no + +# In the below example we are setting attributes in child element processModel +# https://www.iis.net/configreference/system.applicationhost/applicationpools/add/processmodel +- name: Manage child element and set identity of application pool + community.windows.win_iis_webapppool: + name: IdentitiyAppPool + state: started + attributes: + managedPipelineMode: Classic + processModel.identityType: SpecificUser + processModel.userName: '{{ansible_user}}' + processModel.password: '{{ansible_password}}' + processModel.loadUserProfile: true + +- name: Manage a timespan attribute + community.windows.win_iis_webapppool: + name: TimespanAppPool + state: started + attributes: + # Timespan with full string "day:hour:minute:second.millisecond" + recycling.periodicRestart.time: "00:00:05:00.000000" + recycling.periodicRestart.schedule: ["00:10:00", "05:30:00"] + # Shortened timespan "hour:minute:second" + processModel.pingResponseTime: "00:03:00" +''' + +RETURN = r''' +attributes: + description: Application Pool attributes that were set and processed by this + module invocation. + returned: success + type: dict + sample: + enable32BitAppOnWin64: "true" + managedRuntimeVersion: "v4.0" + managedPipelineMode: "Classic" +info: + description: Information on current state of the Application Pool. See + https://www.iis.net/configreference/system.applicationhost/applicationpools/add#005 + for the full list of return attributes based on your IIS version. + returned: success + type: complex + sample: + contains: + attributes: + description: Key value pairs showing the current Application Pool attributes. + returned: success + type: dict + sample: + autoStart: true + managedRuntimeLoader: "webengine4.dll" + managedPipelineMode: "Classic" + name: "DefaultAppPool" + CLRConfigFile: "" + passAnonymousToken: true + applicationPoolSid: "S-1-5-82-1352790163-598702362-1775843902-1923651883-1762956711" + queueLength: 1000 + managedRuntimeVersion: "v4.0" + state: "Started" + enableConfigurationOverride: true + startMode: "OnDemand" + enable32BitAppOnWin64: true + cpu: + description: Key value pairs showing the current Application Pool cpu attributes. + returned: success + type: dict + sample: + action: "NoAction" + limit: 0 + resetInterval: + Days: 0 + Hours: 0 + failure: + description: Key value pairs showing the current Application Pool failure attributes. + returned: success + type: dict + sample: + autoShutdownExe: "" + orphanActionExe: "" + rapidFailProtextionInterval: + Days: 0 + Hours: 0 + name: + description: Name of Application Pool that was processed by this module invocation. + returned: success + type: str + sample: "DefaultAppPool" + processModel: + description: Key value pairs showing the current Application Pool processModel attributes. + returned: success + type: dict + sample: + identityType: "ApplicationPoolIdentity" + logonType: "LogonBatch" + pingInterval: + Days: 0 + Hours: 0 + recycling: + description: Key value pairs showing the current Application Pool recycling attributes. + returned: success + type: dict + sample: + disallowOverlappingRotation: false + disallowRotationOnConfigChange: false + logEventOnRecycle: "Time,Requests,Schedule,Memory,IsapiUnhealthy,OnDemand,ConfigChange,PrivateMemory" + state: + description: Current runtime state of the pool as the module completed. + returned: success + type: str + sample: "Started" +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.ps1 b/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.ps1 new file mode 100644 index 00000000..b6073f4f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.ps1 @@ -0,0 +1,348 @@ +#!powershell + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam $params -name "name" -type str -failifempty $true -aliases 'website' +$state = Get-AnsibleParam $params "state" -default "present" -validateSet "present", "absent" +$host_header = Get-AnsibleParam $params -name "host_header" -type str +$protocol = Get-AnsibleParam $params -name "protocol" -type str -default 'http' +$port = Get-AnsibleParam $params -name "port" -default '80' +$ip = Get-AnsibleParam $params -name "ip" -default '*' +$certificateHash = Get-AnsibleParam $params -name "certificate_hash" -type str -default ([string]::Empty) +$certificateStoreName = Get-AnsibleParam $params -name "certificate_store_name" -type str -default ([string]::Empty) +$sslFlags = Get-AnsibleParam $params -name "ssl_flags" -default '0' -ValidateSet '0', '1', '2', '3' + +$result = @{ + changed = $false +} + +################# +### Functions ### +################# +function New-BindingInfo { + $ht = @{ + 'bindingInformation' = $args[0].bindingInformation + 'ip' = $args[0].bindingInformation.split(':')[0] + 'port' = [int]$args[0].bindingInformation.split(':')[1] + 'hostheader' = $args[0].bindingInformation.split(':')[2] + #'isDsMapperEnabled' = $args[0].isDsMapperEnabled + 'protocol' = $args[0].protocol + 'certificateStoreName' = $args[0].certificateStoreName + 'certificateHash' = $args[0].certificateHash + } + + #handle sslflag support + If ([version][System.Environment]::OSVersion.Version -lt [version]'6.2') { + $ht.sslFlags = 'not supported' + } + Else { + $ht.sslFlags = [int]$args[0].sslFlags + } + + Return $ht +} + +# Used instead of get-webbinding to ensure we always return a single binding +# We can't filter properly with get-webbinding...ex get-webbinding ip * returns all bindings +# pass it $binding_parameters hashtable +function Get-SingleWebBinding { + + Try { + $site_bindings = get-webbinding -name $args[0].name + } + Catch { + # 2k8r2 throws this error when you run get-webbinding with no bindings in iis + $msg = 'Cannot process argument because the value of argument "obj" is null. Change the value of argument "obj" to a non-null value' + If (-not $_.Exception.Message.CompareTo($msg)) { + Throw $_.Exception.Message + } + Else { return } + } + + Foreach ($binding in $site_bindings) { + $splits = $binding.bindingInformation -split ':' + + if ( + $args[0].protocol -eq $binding.protocol -and + $args[0].ipaddress -eq $splits[0] -and + $args[0].port -eq $splits[1] -and + $args[0].hostheader -eq $splits[2] + ) { + Return $binding + } + } +} + + +############################# +### Pre-Action Validation ### +############################# +$os_version = [version][System.Environment]::OSVersion.Version + +# Ensure WebAdministration module is loaded +If ($os_version -lt [version]'6.1') { + Try { + Add-PSSnapin WebAdministration + } + Catch { + Fail-Json -obj $result -message "The WebAdministration snap-in is not present. Please make sure it is installed." + } +} +Else { + Try { + Import-Module WebAdministration + } + Catch { + Fail-Json -obj $result -message "Failed to load WebAdministration module. Is IIS installed? $($_.Exception.Message)" + } +} + +# ensure website targetted exists. -Name filter doesn't work on 2k8r2 so do where-object instead +$website_check = get-website | Where-Object { $_.name -eq $name } +If (-not $website_check) { + Fail-Json -obj $result -message "Unable to retrieve website with name $Name. Make sure the website name is valid and exists." +} + +# if OS older than 2012 (6.2) and ssl flags are set, fail. Otherwise toggle sni_support +If ($os_version -lt [version]'6.2') { + If ($sslFlags -ne 0) { + Fail-Json -obj $result -message "SNI and Certificate Store support is not available for systems older than 2012 (6.2)" + } + $sni_support = $false #will cause the sslflags check later to skip +} +Else { + $sni_support = $true +} + +# make sure ssl flags only specified with https protocol +If ($protocol -ne 'https' -and $sslFlags -gt 0) { + Fail-Json -obj $result -message "SSLFlags can only be set for HTTPS protocol" +} + +# validate certificate details if provided +# we don't do anything with cert on state: absent, so only validate present +If ($certificateHash -and $state -eq 'present') { + If ($protocol -ne 'https') { + Fail-Json -obj $result -message "You can only provide a certificate thumbprint when protocol is set to https" + } + + #apply default for cert store name + If (-Not $certificateStoreName) { + $certificateStoreName = 'my' + } + + #validate cert path + $cert_path = "cert:\LocalMachine\$certificateStoreName\$certificateHash" + If (-Not (Test-Path -LiteralPath $cert_path) ) { + Fail-Json -obj $result -message "Unable to locate certificate at $cert_path" + } +} + +# make sure binding info is valid for central cert store if sslflags -gt 1 +If ($sslFlags -gt 1 -and ($certificateHash -ne [string]::Empty -or $certificateStoreName -ne [string]::Empty)) { + Fail-Json -obj $result -message "You set sslFlags to $sslFlags. This indicates you wish to use the Central Certificate Store feature. + This cannot be used in combination with certficiate_hash and certificate_store_name. When using the Central Certificate Store feature, + the certificate is automatically retrieved from the store rather than manually assigned to the binding." +} + +# disallow host_header: '*' +If ($host_header -eq '*') { + Fail-Json -obj $result -message "To make or remove a catch-all binding, please omit the host_header parameter entirely rather than specify host_header *" +} + +########################## +### start action items ### +########################## + +# create binding search splat +$binding_parameters = @{ + Name = $name + Protocol = $protocol + Port = $port + IPAddress = $ip +} + +# insert host header to search if specified, otherwise it will return * (all bindings matching protocol/ip) +If ($host_header) { + $binding_parameters.HostHeader = $host_header +} +Else { + $binding_parameters.HostHeader = [string]::Empty +} + +# Get bindings matching parameters +Try { + $current_bindings = Get-SingleWebBinding $binding_parameters +} +Catch { + Fail-Json -obj $result -message "Failed to retrieve bindings with Get-SingleWebBinding - $($_.Exception.Message)" +} + +################################################ +### Remove binding or exit if already absent ### +################################################ +If ($current_bindings -and $state -eq 'absent') { + Try { + #there is a bug in this method that will result in all bindings being removed if the IP in $current_bindings is a * + #$current_bindings | Remove-WebBinding -verbose -WhatIf:$check_mode + + #another method that did not work. It kept failing to match on element and removed everything. + #$element = @{protocol="$protocol";bindingInformation="$ip`:$port`:$host_header"} + #Remove-WebconfigurationProperty -filter $current_bindings.ItemXPath -Name Bindings.collection -AtElement $element -WhatIf #:$check_mode + + #this method works + [array]$bindings = Get-WebconfigurationProperty -filter $current_bindings.ItemXPath -Name Bindings.collection + + $index = Foreach ($item in $bindings) { + If ( $protocol -eq $item.protocol -and $current_bindings.bindingInformation -eq $item.bindingInformation ) { + $bindings.indexof($item) + break + } + } + + Remove-WebconfigurationProperty -filter $current_bindings.ItemXPath -Name Bindings.collection -AtIndex $index -WhatIf:$check_mode + $result.changed = $true + } + + Catch { + Fail-Json -obj $result -message "Failed to remove the binding from IIS - $($_.Exception.Message)" + } + + # removing bindings from iis may not also remove them from iis:\sslbindings + + $result.operation_type = 'removed' + $result.binding_info = $current_bindings | ForEach-Object { New-BindingInfo $_ } + Exit-Json -obj $result +} +ElseIf (-Not $current_bindings -and $state -eq 'absent') { + # exit changed: false since it's already gone + Exit-Json -obj $result +} + + +################################ +### Modify existing bindings ### +################################ +<# +since we have already have the parameters available to get-webbinding, +we just need to check here for the ones that are not available which are the +ssl settings (hash, store, sslflags). If they aren't set we update here, or +exit with changed: false +#> +ElseIf ($current_bindings) { + #ran into a strange edge case in testing where I was able to retrieve bindings but not expand all the properties + #when adding a self-signed wildcard cert to a binding. it seemed to permanently break the binding. only removing it + #would cause the error to stop. + Try { + $null = $current_bindings | Select-Object * + } + Catch { + $msg = -join @( + "Found a matching binding, but failed to expand it's properties (get-binding | FL *). " + "In testing, this was caused by using a self-signed wildcard certificate. $($_.Exception.Message)" + ) + Fail-Json -obj $result -message $msg + } + + # check if there is a match on the ssl parameters + If ( ($current_bindings.sslFlags -ne $sslFlags -and $sni_support) -or + $current_bindings.certificateHash -ne $certificateHash -or + $current_bindings.certificateStoreName -ne $certificateStoreName) { + # match/update SNI + If ($current_bindings.sslFlags -ne $sslFlags -and $sni_support) { + Try { + Set-WebBinding -Name $name -IPAddress $ip -Port $port -HostHeader $host_header -PropertyName sslFlags -value $sslFlags -whatif:$check_mode + $result.changed = $true + } + Catch { + Fail-Json -obj $result -message "Failed to update sslFlags on binding - $($_.Exception.Message)" + } + + # Refresh the binding object since it has been changed + Try { + $current_bindings = Get-SingleWebBinding $binding_parameters + } + Catch { + Fail-Json -obj $result -message "Failed to refresh bindings after setting sslFlags - $($_.Exception.Message)" + } + } + # match/update certificate + If ($current_bindings.certificateHash -ne $certificateHash -or $current_bindings.certificateStoreName -ne $certificateStoreName) { + If (-Not $check_mode) { + Try { + $current_bindings.AddSslCertificate($certificateHash, $certificateStoreName) + } + Catch { + Fail-Json -obj $result -message "Failed to set new SSL certificate - $($_.Exception.Message)" + } + } + } + $result.changed = $true + $result.operation_type = 'updated' + $result.website_state = (Get-Website | Where-Object { $_.Name -eq $Name }).State + $result.binding_info = New-BindingInfo (Get-SingleWebBinding $binding_parameters) + Exit-Json -obj $result #exit changed true + } + Else { + $result.operation_type = 'matched' + $result.website_state = (Get-Website | Where-Object { $_.Name -eq $Name }).State + $result.binding_info = New-BindingInfo (Get-SingleWebBinding $binding_parameters) + Exit-Json -obj $result #exit changed false + } +} + +######################## +### Add new bindings ### +######################## +ElseIf (-not $current_bindings -and $state -eq 'present') { + # add binding. this creates the binding, but does not apply a certificate to it. + Try { + If (-not $check_mode) { + If ($sni_support) { + New-WebBinding @binding_parameters -SslFlags $sslFlags -Force + } + Else { + New-WebBinding @binding_parameters -Force + } + } + $result.changed = $true + } + Catch { + $result.website_state = (Get-Website | Where-Object { $_.Name -eq $Name }).State + Fail-Json -obj $result -message "Failed at creating new binding (note: creating binding and adding ssl are separate steps) - $($_.Exception.Message)" + } + + # add certificate to binding + If ($certificateHash -and -not $check_mode) { + Try { + #$new_binding = get-webbinding -Name $name -IPAddress $ip -port $port -Protocol $protocol -hostheader $host_header + $new_binding = Get-SingleWebBinding $binding_parameters + $new_binding.addsslcertificate($certificateHash, $certificateStoreName) + } + Catch { + $result.website_state = (Get-Website | Where-Object { $_.Name -eq $Name }).State + Fail-Json -obj $result -message "Failed to set new SSL certificate - $($_.Exception.Message)" + } + } + + $result.changed = $true + $result.operation_type = 'added' + $result.website_state = (Get-Website | Where-Object { $_.Name -eq $Name }).State + + # incase there are no bindings we do a check before calling New-BindingInfo + $web_binding = Get-SingleWebBinding $binding_parameters + if ($web_binding) { + $result.binding_info = New-BindingInfo $web_binding + } + else { + $result.binding_info = $null + } + Exit-Json $result +} diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.py b/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.py new file mode 100644 index 00000000..cf3d42b1 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_webbinding.py @@ -0,0 +1,145 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Noah Sparks <nsparks@outlook.com> +# Copyright: (c) 2017, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_iis_webbinding +short_description: Configures a IIS Web site binding +description: + - Creates, removes and configures a binding to an existing IIS Web site. +options: + name: + description: + - Names of web site. + type: str + required: yes + aliases: [ website ] + state: + description: + - State of the binding. + type: str + choices: [ absent, present ] + default: present + port: + description: + - The port to bind to / use for the new site. + type: int + default: 80 + ip: + description: + - The IP address to bind to / use for the new site. + type: str + default: '*' + host_header: + description: + - The host header to bind to / use for the new site. + - If you are creating/removing a catch-all binding, omit this parameter rather than defining it as '*'. + type: str + protocol: + description: + - The protocol to be used for the Web binding (usually HTTP, HTTPS, or FTP). + type: str + default: http + certificate_hash: + description: + - Certificate hash (thumbprint) for the SSL binding. The certificate hash is the unique identifier for the certificate. + type: str + certificate_store_name: + description: + - Name of the certificate store where the certificate for the binding is located. + type: str + default: my + ssl_flags: + description: + - This parameter is only valid on Server 2012 and newer. + - Primarily used for enabling and disabling server name indication (SNI). + - Set to C(0) to disable SNI. + - Set to C(1) to enable SNI. + type: str +seealso: +- module: community.windows.win_iis_virtualdirectory +- module: community.windows.win_iis_webapplication +- module: community.windows.win_iis_webapppool +- module: community.windows.win_iis_website +author: + - Noah Sparks (@nwsparks) + - Henrik Wallström (@henrikwallstrom) +''' + +EXAMPLES = r''' +- name: Add a HTTP binding on port 9090 + community.windows.win_iis_webbinding: + name: Default Web Site + port: 9090 + state: present + +- name: Remove the HTTP binding on port 9090 + community.windows.win_iis_webbinding: + name: Default Web Site + port: 9090 + state: absent + +- name: Remove the default http binding + community.windows.win_iis_webbinding: + name: Default Web Site + port: 80 + ip: '*' + state: absent + +- name: Add a HTTPS binding + community.windows.win_iis_webbinding: + name: Default Web Site + protocol: https + port: 443 + ip: 127.0.0.1 + certificate_hash: B0D0FA8408FC67B230338FCA584D03792DA73F4C + state: present + +- name: Add a HTTPS binding with host header and SNI enabled + community.windows.win_iis_webbinding: + name: Default Web Site + protocol: https + port: 443 + host_header: test.com + ssl_flags: 1 + certificate_hash: D1A3AF8988FD32D1A3AF8988FD323792DA73F4C + state: present +''' + +RETURN = r''' +website_state: + description: + - The state of the website being targetted + - Can be helpful in case you accidentally cause a binding collision + which can result in the targetted site being stopped + returned: always + type: str + sample: "Started" +operation_type: + description: + - The type of operation performed + - Can be removed, updated, matched, or added + returned: on success + type: str + sample: "removed" +binding_info: + description: + - Information on the binding being manipulated + returned: on success + type: dict + sample: |- + "binding_info": { + "bindingInformation": "127.0.0.1:443:", + "certificateHash": "FF3910CE089397F1B5A77EB7BAFDD8F44CDE77DD", + "certificateStoreName": "MY", + "hostheader": "", + "ip": "127.0.0.1", + "port": 443, + "protocol": "https", + "sslFlags": "not supported" + } +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_website.ps1 b/ansible_collections/community/windows/plugins/modules/win_iis_website.ps1 new file mode 100644 index 00000000..571fb3d5 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_website.ps1 @@ -0,0 +1,175 @@ +#!powershell + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +$params = Parse-Args $args +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$application_pool = Get-AnsibleParam -obj $params -name "application_pool" -type "str" +$physical_path = Get-AnsibleParam -obj $params -name "physical_path" -type "str" +$site_id = Get-AnsibleParam -obj $params -name "site_id" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -validateset "absent", "restarted", "started", "stopped" + +# Binding Parameters +$bind_port = Get-AnsibleParam -obj $params -name "port" -type "int" +$bind_ip = Get-AnsibleParam -obj $params -name "ip" -type "str" +$bind_hostname = Get-AnsibleParam -obj $params -name "hostname" -type "str" + +# Custom site Parameters from string where properties +# are separated by a pipe and property name/values by colon. +# Ex. "foo:1|bar:2" +$parameters = Get-AnsibleParam -obj $params -name "parameters" -type "str" +if ($null -ne $parameters) { + $parameters = @($parameters -split '\|' | ForEach-Object { + return , ($_ -split "\:", 2) + }) +} + + +# Ensure WebAdministration module is loaded +if ($null -eq (Get-Module "WebAdministration" -ErrorAction SilentlyContinue)) { + Import-Module WebAdministration +} + +# Result +$result = @{ + site = @{} + changed = $false +} + +# Site info +$site = Get-Website | Where-Object { $_.Name -eq $name } + +Try { + # Add site + If (($state -ne 'absent') -and (-not $site)) { + If (-not $physical_path) { + Fail-Json -obj $result -message "missing required arguments: physical_path" + } + ElseIf (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json -obj $result -message "specified folder must already exist: physical_path" + } + + $site_parameters = @{ + Name = $name + PhysicalPath = $physical_path + } + + If ($application_pool) { + $site_parameters.ApplicationPool = $application_pool + } + + If ($site_id) { + $site_parameters.ID = $site_id + } + + If ($bind_port) { + $site_parameters.Port = $bind_port + } + + If ($bind_ip) { + $site_parameters.IPAddress = $bind_ip + } + + If ($bind_hostname) { + $site_parameters.HostHeader = $bind_hostname + } + + # Fix for error "New-Item : Index was outside the bounds of the array." + # This is a bug in the New-WebSite commandlet. Apparently there must be at least one site configured in IIS otherwise New-WebSite crashes. + # For more details, see http://stackoverflow.com/questions/3573889/ps-c-new-website-blah-throws-index-was-outside-the-bounds-of-the-array + $sites_list = Get-ChildItem -LiteralPath IIS:\sites + if ($null -eq $sites_list) { + if ($site_id) { + $site_parameters.ID = $site_id + } + else { + $site_parameters.ID = 1 + } + } + + $site = New-Website @site_parameters -Force + $result.changed = $true + } + + # Remove site + If ($state -eq 'absent' -and $site) { + $site = Remove-Website -Name $name + $result.changed = $true + } + + $site = Get-Website | Where-Object { $_.Name -eq $name } + If ($site) { + # Change Physical Path if needed + if ($physical_path) { + If (-not (Test-Path -LiteralPath $physical_path)) { + Fail-Json -obj $result -message "specified folder must already exist: physical_path" + } + + $folder = Get-Item -LiteralPath $physical_path + If ($folder.FullName -ne $site.PhysicalPath) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site.Name)" -name physicalPath -value $folder.FullName + $result.changed = $true + } + } + + # Change Application Pool if needed + if ($application_pool) { + If ($application_pool -ne $site.applicationPool) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site.Name)" -name applicationPool -value $application_pool + $result.changed = $true + } + } + + # Set properties + if ($parameters) { + $parameters | ForEach-Object { + $property_value = Get-ItemProperty -LiteralPath "IIS:\Sites\$($site.Name)" $_[0] + + switch ($property_value.GetType().Name) { + "ConfigurationAttribute" { $parameter_value = $property_value.value } + "String" { $parameter_value = $property_value } + } + + if ((-not $parameter_value) -or ($parameter_value) -ne $_[1]) { + Set-ItemProperty -LiteralPath "IIS:\Sites\$($site.Name)" $_[0] $_[1] + $result.changed = $true + } + } + } + + # Set run state + if ((($state -eq 'stopped') -or ($state -eq 'restarted')) -and ($site.State -eq 'Started')) { + Stop-Website -Name $name -ErrorAction Stop + $result.changed = $true + } + if ((($state -eq 'started') -and ($site.State -eq 'Stopped')) -or ($state -eq 'restarted')) { + Start-Website -Name $name -ErrorAction Stop + $result.changed = $true + } + } +} +Catch { + Fail-Json -obj $result -message $_.Exception.Message +} + +if ($state -ne 'absent') { + $site = Get-Website | Where-Object { $_.Name -eq $name } +} + +if ($site) { + $result.site = @{ + Name = $site.Name + ID = $site.ID + State = $site.State + PhysicalPath = $site.PhysicalPath + ApplicationPool = $site.applicationPool + Bindings = @($site.Bindings.Collection | ForEach-Object { $_.BindingInformation }) + } +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_iis_website.py b/ansible_collections/community/windows/plugins/modules/win_iis_website.py new file mode 100644 index 00000000..c1fe192d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_iis_website.py @@ -0,0 +1,130 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Henrik Wallström <henrik@wallstroms.nu> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_iis_website +short_description: Configures a IIS Web site +description: + - Creates, Removes and configures a IIS Web site. +options: + name: + description: + - Names of web site. + type: str + required: yes + site_id: + description: + - Explicitly set the IIS numeric ID for a site. + - Note that this value cannot be changed after the website has been created. + type: str + state: + description: + - State of the web site + type: str + choices: [ absent, started, stopped, restarted ] + physical_path: + description: + - The physical path on the remote host to use for the new site. + - The specified folder must already exist. + type: str + application_pool: + description: + - The application pool in which the new site executes. + type: str + port: + description: + - The port to bind to / use for the new site. + type: int + ip: + description: + - The IP address to bind to / use for the new site. + type: str + hostname: + description: + - The host header to bind to / use for the new site. + type: str + parameters: + description: + - Custom site Parameters from string where properties are separated by a pipe and property name/values by colon Ex. "foo:1|bar:2" + - Some custom parameters that you can use are listed below, this isn't a definitive list but some common parameters. + - C(logfile.directory) - Physical path to store Logs, e.g. C(D:\IIS-LOGs\). + - C(logfile.period) - Log file rollover scheduled accepting these values, how frequently the log file should be rolled-over, + e.g. C(Hourly, Daily, Weekly, Monthly). + - C(logfile.LogFormat) - Log file format, by default IIS uses C(W3C). + - C(logfile.truncateSize) - The size at which the log file contents will be trunsted, expressed in bytes. + type: str +seealso: +- module: community.windows.win_iis_virtualdirectory +- module: community.windows.win_iis_webapplication +- module: community.windows.win_iis_webapppool +- module: community.windows.win_iis_webbinding +author: +- Henrik Wallström (@henrikwallstrom) +''' + +EXAMPLES = r''' + +# Start a website + +- name: Acme IIS site + community.windows.win_iis_website: + name: Acme + state: started + port: 80 + ip: 127.0.0.1 + hostname: acme.local + application_pool: acme + physical_path: C:\sites\acme + parameters: logfile.directory:C:\sites\logs + register: website + +# Remove Default Web Site and the standard port 80 binding +- name: Remove Default Web Site + community.windows.win_iis_website: + name: "Default Web Site" + state: absent + +# Create a WebSite with custom Logging configuration (Logs Location, Format and Rolling Over). + +- name: Creating WebSite with Custom Log location, Format 3WC and rolling over every hour. + community.windows.win_iis_website: + name: MyCustom_Web_Shop_Site + state: started + port: 80 + ip: '*' + hostname: '*' + physical_path: D:\wwwroot\websites\my-shop-site + parameters: logfile.directory:D:\IIS-LOGS\websites\my-shop-site|logfile.period:Hourly|logFile.logFormat:W3C + application_pool: my-shop-site + +# Some commandline examples: + +# This return information about an existing host +# $ ansible -i vagrant-inventory -m community.windows.win_iis_website -a "name='Default Web Site'" window +# host | success >> { +# "changed": false, +# "site": { +# "ApplicationPool": "DefaultAppPool", +# "Bindings": [ +# "*:80:" +# ], +# "ID": 1, +# "Name": "Default Web Site", +# "PhysicalPath": "%SystemDrive%\\inetpub\\wwwroot", +# "State": "Stopped" +# } +# } + +# This stops an existing site. +# $ ansible -i hosts -m community.windows.win_iis_website -a "name='Default Web Site' state=stopped" host + +# This creates a new site. +# $ ansible -i hosts -m community.windows.win_iis_website -a "name=acme physical_path=C:\\sites\\acme" host + +# Change logfile. +# $ ansible -i hosts -m community.windows.win_iis_website -a "name=acme physical_path=C:\\sites\\acme" host +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_inet_proxy.ps1 b/ansible_collections/community/windows/plugins/modules/win_inet_proxy.ps1 new file mode 100644 index 00000000..769a8f72 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_inet_proxy.ps1 @@ -0,0 +1,496 @@ +#!powershell + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + auto_detect = @{ type = "bool"; default = $true } + auto_config_url = @{ type = "str" } + proxy = @{ type = "raw" } + bypass = @{ type = "list"; elements = "str"; no_log = $false } + connection = @{ type = "str" } + } + required_by = @{ + bypass = @("proxy") + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$auto_detect = $module.Params.auto_detect +$auto_config_url = $module.Params.auto_config_url +$proxy = $module.Params.proxy +$bypass = $module.Params.bypass +$connection = $module.Params.connection + +# Parse the raw value, it should be a Dictionary or String +if ($proxy -is [System.Collections.IDictionary]) { + $valid_keys = [System.Collections.Generic.List`1[String]]@("http", "https", "ftp", "socks") + # Check to make sure we don't have any invalid keys in the dict + $invalid_keys = [System.Collections.Generic.List`1[String]]@() + foreach ($k in $proxy.Keys) { + if ($k -notin $valid_keys) { + $invalid_keys.Add($k) + } + } + + if ($invalid_keys.Count -gt 0) { + $invalid_keys = $invalid_keys | Sort-Object # So our test assertion doesn't fail due to random ordering + $module.FailJson("Invalid keys found in proxy: $($invalid_keys -join ', '). Valid keys are $($valid_keys -join ', ').") + } + + # Build the proxy string in the form 'protocol=host;', the order of valid_keys is also important + $proxy_list = [System.Collections.Generic.List`1[String]]@() + foreach ($k in $valid_keys) { + if ($proxy.ContainsKey($k)) { + $proxy_list.Add("$k=$($proxy.$k)") + } + } + $proxy = $proxy_list -join ";" +} +elseif ($null -ne $proxy) { + $proxy = $proxy.ToString() +} + +if ($bypass) { + if ([System.String]::IsNullOrEmpty($proxy)) { + $module.FailJson("missing parameter(s) required by ''bypass'': proxy") + } + $bypass = $bypass -join ';' +} + +$win_inet_invoke = @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace Ansible.WinINetProxy +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class INTERNET_PER_CONN_OPTION_LISTW : IDisposable + { + public UInt32 dwSize; + public IntPtr pszConnection; + public UInt32 dwOptionCount; + public UInt32 dwOptionError; + public IntPtr pOptions; + + public INTERNET_PER_CONN_OPTION_LISTW() + { + dwSize = (UInt32)Marshal.SizeOf(this); + } + + public void Dispose() + { + if (pszConnection != IntPtr.Zero) + Marshal.FreeHGlobal(pszConnection); + if (pOptions != IntPtr.Zero) + Marshal.FreeHGlobal(pOptions); + GC.SuppressFinalize(this); + } + ~INTERNET_PER_CONN_OPTION_LISTW() { this.Dispose(); } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public class INTERNET_PER_CONN_OPTIONW : IDisposable + { + public INTERNET_PER_CONN_OPTION dwOption; + public ValueUnion Value; + + [StructLayout(LayoutKind.Explicit)] + public class ValueUnion + { + [FieldOffset(0)] + public UInt32 dwValue; + + [FieldOffset(0)] + public IntPtr pszValue; + + [FieldOffset(0)] + public System.Runtime.InteropServices.ComTypes.FILETIME ftValue; + } + + public void Dispose() + { + // We can't just check if Value.pszValue is not IntPtr.Zero as the union means it could be set even + // when the value is a UInt32 or FILETIME. We check against a known string option type and only free + // the value in those cases. + List<INTERNET_PER_CONN_OPTION> stringOptions = new List<INTERNET_PER_CONN_OPTION> + { + { INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_AUTOCONFIG_URL }, + { INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_BYPASS }, + { INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_SERVER } + }; + if (Value != null && Value.pszValue != IntPtr.Zero && stringOptions.Contains(dwOption)) + Marshal.FreeHGlobal(Value.pszValue); + GC.SuppressFinalize(this); + } + ~INTERNET_PER_CONN_OPTIONW() { this.Dispose(); } + } + + public enum INTERNET_OPTION : uint + { + INTERNET_OPTION_PER_CONNECTION_OPTION = 75, + INTERNET_OPTION_PROXY_SETTINGS_CHANGED = 95, + } + + public enum INTERNET_PER_CONN_OPTION : uint + { + INTERNET_PER_CONN_FLAGS = 1, + INTERNET_PER_CONN_PROXY_SERVER = 2, + INTERNET_PER_CONN_PROXY_BYPASS = 3, + INTERNET_PER_CONN_AUTOCONFIG_URL = 4, + INTERNET_PER_CONN_AUTODISCOVERY_FLAGS = 5, + INTERNET_PER_CONN_FLAGS_UI = 10, // IE8+ - Included with Windows 7 and Server 2008 R2 + } + + [Flags] + public enum PER_CONN_FLAGS : uint + { + PROXY_TYPE_DIRECT = 0x00000001, + PROXY_TYPE_PROXY = 0x00000002, + PROXY_TYPE_AUTO_PROXY_URL = 0x00000004, + PROXY_TYPE_AUTO_DETECT = 0x00000008, + } + } + + internal class NativeMethods + { + [DllImport("Wininet.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool InternetQueryOptionW( + IntPtr hInternet, + NativeHelpers.INTERNET_OPTION dwOption, + SafeMemoryBuffer lpBuffer, + ref UInt32 lpdwBufferLength); + + [DllImport("Wininet.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool InternetSetOptionW( + IntPtr hInternet, + NativeHelpers.INTERNET_OPTION dwOption, + SafeMemoryBuffer lpBuffer, + UInt32 dwBufferLength); + + [DllImport("Rasapi32.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 RasValidateEntryNameW( + string lpszPhonebook, + string lpszEntry); + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class WinINetProxy + { + private string Connection; + + public string AutoConfigUrl; + public bool AutoDetect; + public string Proxy; + public string ProxyBypass; + + public WinINetProxy(string connection) + { + Connection = connection; + Refresh(); + } + + public static bool IsValidConnection(string name) + { + // RasValidateEntryName is used to verify is a name can be a valid phonebook entry. It returns 0 if no + // entry exists and 183 if it already exists. We just need to check if it returns 183 to verify the + // connection name. + return NativeMethods.RasValidateEntryNameW(null, name) == 183; + } + + public void Refresh() + { + using (var connFlags = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_FLAGS_UI)) + using (var autoConfigUrl = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_AUTOCONFIG_URL)) + using (var server = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_SERVER)) + using (var bypass = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_BYPASS)) + { + NativeHelpers.INTERNET_PER_CONN_OPTIONW[] options = new NativeHelpers.INTERNET_PER_CONN_OPTIONW[] + { + connFlags, autoConfigUrl, server, bypass + }; + + try + { + QueryOption(options, Connection); + } + catch (Win32Exception e) + { + if (e.NativeErrorCode == 87) // ERROR_INVALID_PARAMETER + { + // INTERNET_PER_CONN_FLAGS_UI only works for IE8+, try the fallback in case we are still working + // with an ancient version. + connFlags.dwOption = NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_FLAGS; + QueryOption(options, Connection); + } + else + throw; + } + + NativeHelpers.PER_CONN_FLAGS flags = (NativeHelpers.PER_CONN_FLAGS)connFlags.Value.dwValue; + + AutoConfigUrl = flags.HasFlag(NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_AUTO_PROXY_URL) + ? Marshal.PtrToStringUni(autoConfigUrl.Value.pszValue) : null; + AutoDetect = flags.HasFlag(NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_AUTO_DETECT); + if (flags.HasFlag(NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_PROXY)) + { + Proxy = Marshal.PtrToStringUni(server.Value.pszValue); + ProxyBypass = Marshal.PtrToStringUni(bypass.Value.pszValue); + } + else + { + Proxy = null; + ProxyBypass = null; + } + } + } + + public void Set() + { + using (var connFlags = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_FLAGS_UI)) + using (var autoConfigUrl = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_AUTOCONFIG_URL)) + using (var server = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_SERVER)) + using (var bypass = CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION.INTERNET_PER_CONN_PROXY_BYPASS)) + { + List<NativeHelpers.INTERNET_PER_CONN_OPTIONW> options = new List<NativeHelpers.INTERNET_PER_CONN_OPTIONW>(); + + // PROXY_TYPE_DIRECT seems to always be set, need to verify + NativeHelpers.PER_CONN_FLAGS flags = NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_DIRECT; + if (AutoDetect) + flags |= NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_AUTO_DETECT; + + if (!String.IsNullOrEmpty(AutoConfigUrl)) + { + flags |= NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_AUTO_PROXY_URL; + autoConfigUrl.Value.pszValue = Marshal.StringToHGlobalUni(AutoConfigUrl); + } + options.Add(autoConfigUrl); + + if (!String.IsNullOrEmpty(Proxy)) + { + flags |= NativeHelpers.PER_CONN_FLAGS.PROXY_TYPE_PROXY; + server.Value.pszValue = Marshal.StringToHGlobalUni(Proxy); + } + options.Add(server); + + if (!String.IsNullOrEmpty(ProxyBypass)) + bypass.Value.pszValue = Marshal.StringToHGlobalUni(ProxyBypass); + options.Add(bypass); + + connFlags.Value.dwValue = (UInt32)flags; + options.Add(connFlags); + + SetOption(options.ToArray(), Connection); + + // Tell IE that the proxy settings have been changed. + if (!NativeMethods.InternetSetOptionW( + IntPtr.Zero, + NativeHelpers.INTERNET_OPTION.INTERNET_OPTION_PROXY_SETTINGS_CHANGED, + new SafeMemoryBuffer(IntPtr.Zero), + 0)) + { + throw new Win32Exception("InternetSetOptionW(INTERNET_OPTION_PROXY_SETTINGS_CHANGED) failed"); + } + } + } + + internal static NativeHelpers.INTERNET_PER_CONN_OPTIONW CreateConnOption(NativeHelpers.INTERNET_PER_CONN_OPTION option) + { + return new NativeHelpers.INTERNET_PER_CONN_OPTIONW + { + dwOption = option, + Value = new NativeHelpers.INTERNET_PER_CONN_OPTIONW.ValueUnion(), + }; + } + + internal static void QueryOption(NativeHelpers.INTERNET_PER_CONN_OPTIONW[] options, string connection = null) + { + using (NativeHelpers.INTERNET_PER_CONN_OPTION_LISTW optionList = new NativeHelpers.INTERNET_PER_CONN_OPTION_LISTW()) + using (SafeMemoryBuffer optionListPtr = MarshalOptionList(optionList, options, connection)) + { + UInt32 bufferSize = optionList.dwSize; + if (!NativeMethods.InternetQueryOptionW( + IntPtr.Zero, + NativeHelpers.INTERNET_OPTION.INTERNET_OPTION_PER_CONNECTION_OPTION, + optionListPtr, + ref bufferSize)) + { + throw new Win32Exception("InternetQueryOptionW(INTERNET_OPTION_PER_CONNECTION_OPTION) failed"); + } + + for (int i = 0; i < options.Length; i++) + { + IntPtr opt = IntPtr.Add(optionList.pOptions, i * Marshal.SizeOf(typeof(NativeHelpers.INTERNET_PER_CONN_OPTIONW))); + NativeHelpers.INTERNET_PER_CONN_OPTIONW option = (NativeHelpers.INTERNET_PER_CONN_OPTIONW)Marshal.PtrToStructure(opt, + typeof(NativeHelpers.INTERNET_PER_CONN_OPTIONW)); + options[i].Value = option.Value; + option.Value = null; // Stops the GC from freeing the same memory twice + } + } + } + + internal static void SetOption(NativeHelpers.INTERNET_PER_CONN_OPTIONW[] options, string connection = null) + { + using (NativeHelpers.INTERNET_PER_CONN_OPTION_LISTW optionList = new NativeHelpers.INTERNET_PER_CONN_OPTION_LISTW()) + using (SafeMemoryBuffer optionListPtr = MarshalOptionList(optionList, options, connection)) + { + if (!NativeMethods.InternetSetOptionW( + IntPtr.Zero, + NativeHelpers.INTERNET_OPTION.INTERNET_OPTION_PER_CONNECTION_OPTION, + optionListPtr, + optionList.dwSize)) + { + throw new Win32Exception("InternetSetOptionW(INTERNET_OPTION_PER_CONNECTION_OPTION) failed"); + } + } + } + + internal static SafeMemoryBuffer MarshalOptionList(NativeHelpers.INTERNET_PER_CONN_OPTION_LISTW optionList, + NativeHelpers.INTERNET_PER_CONN_OPTIONW[] options, string connection) + { + optionList.pszConnection = Marshal.StringToHGlobalUni(connection); + optionList.dwOptionCount = (UInt32)options.Length; + + int optionSize = Marshal.SizeOf(typeof(NativeHelpers.INTERNET_PER_CONN_OPTIONW)); + optionList.pOptions = Marshal.AllocHGlobal(optionSize * options.Length); + for (int i = 0; i < options.Length; i++) + { + IntPtr option = IntPtr.Add(optionList.pOptions, i * optionSize); + Marshal.StructureToPtr(options[i], option, false); + } + + SafeMemoryBuffer optionListPtr = new SafeMemoryBuffer((int)optionList.dwSize); + Marshal.StructureToPtr(optionList, optionListPtr.DangerousGetHandle(), false); + return optionListPtr; + } + } +} +'@ +Add-CSharpType -References $win_inet_invoke -AnsibleModule $module + +# We need to validate the connection because WinINet will just silently continue even if the connection does not +# already exist. +if ($null -ne $connection -and -not [Ansible.WinINetProxy.WinINetProxy]::IsValidConnection($connection)) { + $module.FailJson("The connection '$connection' does not exist.") +} + +$actual_proxy = New-Object -TypeName Ansible.WinINetProxy.WinINetProxy -ArgumentList @(, $connection) +$module.Diff.before = @{ + auto_config_url = $actual_proxy.AutoConfigUrl + auto_detect = $actual_proxy.AutoDetect + bypass = $actual_proxy.ProxyBypass + server = $actual_proxy.Proxy +} + +# Make sure an empty string is converted to $null for easier comparisons +if ([String]::IsNullOrEmpty($auto_config_url)) { + $auto_config_url = $null +} +if ([String]::IsNullOrEmpty($proxy)) { + $proxy = $null +} +if ([String]::IsNullOrEmpty($bypass)) { + $bypass = $null +} + +# Record the original values in case we need to revert on a failure +$previous_auto_config_url = $actual_proxy.AutoConfigUrl +$previous_auto_detect = $actual_proxy.AutoDetect +$previous_proxy = $actual_proxy.Proxy +$previous_bypass = $actual_proxy.ProxyBypass + +$changed = $false +if ($auto_config_url -ne $previous_auto_config_url) { + $actual_proxy.AutoConfigUrl = $auto_config_url + $changed = $true +} + +if ($auto_detect -ne $previous_auto_detect) { + $actual_proxy.AutoDetect = $auto_detect + $changed = $true +} + +if ($proxy -ne $previous_proxy) { + $actual_proxy.Proxy = $proxy + $changed = $true +} + +if ($bypass -ne $previous_bypass) { + $actual_proxy.ProxyBypass = $bypass + $changed = $true +} + +if ($changed -and -not $module.CheckMode) { + $actual_proxy.Set() + + # Validate that the change was made correctly and revert if it wasn't. THe Set() method won't fail on invalid + # values so we need to check again to make sure all was good + $actual_proxy.Refresh() + if ($actual_proxy.AutoConfigUrl -ne $auto_config_url -or + $actual_proxy.AutoDetect -ne $auto_detect -or + $actual_proxy.Proxy -ne $proxy -or + $actual_proxy.ProxyBypass -ne $bypass) { + + $actual_proxy.AutoConfigUrl = $previous_auto_config_url + $actual_proxy.AutoDetect = $previous_auto_detect + $actual_proxy.Proxy = $previous_proxy + $actual_proxy.ProxyBypass = $previous_bypass + $actual_proxy.Set() + + $module.FailJson("Unknown error when trying to set auto_config_url '$auto_config_url', proxy '$proxy', or bypass '$bypass'") + } +} +$module.Result.changed = $changed + +$module.Diff.after = @{ + auto_config_url = $auto_config_url + auto_detect = $auto_detect + bypass = $bypass + proxy = $proxy +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_inet_proxy.py b/ansible_collections/community/windows/plugins/modules/win_inet_proxy.py new file mode 100644 index 00000000..2810606b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_inet_proxy.py @@ -0,0 +1,171 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +DOCUMENTATION = r''' +--- +module: win_inet_proxy +short_description: Manages proxy settings for WinINet and Internet Explorer +description: +- Used to set or remove proxy settings for Windows INet which includes Internet + Explorer. +- WinINet is a framework used by interactive applications to submit web + requests through. +- The proxy settings can also be used by other applications like Firefox, + Chrome, and others but there is no definitive list. +options: + auto_detect: + description: + - Whether to configure WinINet to automatically detect proxy settings + through Web Proxy Auto-Detection C(WPAD). + - This corresponds to the checkbox I(Automatically detect settings) in the + connection settings window. + default: yes + type: bool + auto_config_url: + description: + - The URL of a proxy configuration script. + - Proxy configuration scripts are typically JavaScript files with the + C(.pac) extension that implement the C(FindProxyForURL(url, host) + function. + - Omit, set to null or an empty string to remove the auto config URL. + - This corresponds to the checkbox I(Use automatic configuration script) in + the connection settings window. + type: str + bypass: + description: + - A list of hosts that will bypass the set proxy when being accessed. + - Use C(<local>) to match hostnames that are not fully qualified domain + names. This is useful when needing to connect to intranet sites using + just the hostname. If defined, this should be the last entry in the + bypass list. + - Use C(<-loopback>) to stop automatically bypassing the proxy when + connecting through any loopback address like C(127.0.0.1), C(localhost), + or the local hostname. + - Omit, set to null or an empty string/list to remove the bypass list. + - If this is set then I(proxy) must also be set. + type: list + elements: str + connection: + description: + - The name of the IE connection to set the proxy settings for. + - These are the connections under the I(Dial-up and Virtual Private Network) + header in the IE settings. + - When omitted, the default LAN connection is used. + type: str + proxy: + description: + - A string or dict that specifies the proxy to be set. + - If setting a string, should be in the form C(hostname), C(hostname:port), + or C(protocol=hostname:port). + - If the port is undefined, the default port for the protocol in use is + used. + - If setting a dict, the keys should be the protocol and the values should + be the hostname and/or port for that protocol. + - Valid protocols are C(http), C(https), C(ftp), and C(socks). + - Omit, set to null or an empty string to remove the proxy settings. + type: raw +notes: +- This is not the same as the proxy settings set in WinHTTP through the + C(netsh) command. Use the M(community.windows.win_http_proxy) module to manage that instead. +- These settings are by default set per user and not system wide. A registry + property must be set independently from this module if you wish to apply the + proxy for all users. See examples for more detail. +- If per user proxy settings are desired, use I(become) to become any local + user on the host. No password is needed to be set for this to work. +- If the proxy requires authentication, set the credentials using the + M(community.windows.win_credential) module. This requires I(become) to be used so the + credential store can be accessed. +seealso: +- module: community.windows.win_http_proxy +- module: community.windows.win_credential +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +# This should be set before running the win_inet_proxy module +- name: Configure IE proxy settings to apply to all users + ansible.windows.win_regedit: + path: HKLM:\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings + name: ProxySettingsPerUser + data: 0 + type: dword + state: present + +# This should be set before running the win_inet_proxy module +- name: Configure IE proxy settings to apply per user + ansible.windows.win_regedit: + path: HKLM:\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings + name: ProxySettingsPerUser + data: 1 + type: dword + state: present + +- name: Configure IE proxy to use auto detected settings without an explicit proxy + win_inet_proxy: + auto_detect: yes + +- name: Configure IE proxy to use auto detected settings with a configuration script + win_inet_proxy: + auto_detect: yes + auto_config_url: http://proxy.ansible.com/proxy.pac + +- name: Configure IE to use explicit proxy host + win_inet_proxy: + auto_detect: yes + proxy: ansible.proxy + +- name: Configure IE to use explicit proxy host with port and without auto detection + win_inet_proxy: + auto_detect: no + proxy: ansible.proxy:8080 + +- name: Configure IE to use a specific proxy per protocol + win_inet_proxy: + proxy: + http: ansible.proxy:8080 + https: ansible.proxy:8443 + +- name: Configure IE to use a specific proxy per protocol using a string + win_inet_proxy: + proxy: http=ansible.proxy:8080;https=ansible.proxy:8443 + +- name: Set a proxy with a bypass list + win_inet_proxy: + proxy: ansible.proxy + bypass: + - server1 + - server2 + - <-loopback> + - <local> + +- name: Remove any explicit proxies that are set + win_inet_proxy: + proxy: '' + bypass: '' + +# This should be done after setting the IE proxy with win_inet_proxy +- name: Import IE proxy configuration to WinHTTP + win_http_proxy: + source: ie + +# Explicit credentials can only be set per user and require become to work +- name: Set credential to use for proxy auth + win_credential: + name: ansible.proxy # The name should be the FQDN of the proxy host + type: generic_password + username: proxyuser + secret: proxypass + state: present + become: yes + become_user: '{{ ansible_user }}' + become_method: runas +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_initialize_disk.ps1 b/ansible_collections/community/windows/plugins/modules/win_initialize_disk.ps1 new file mode 100644 index 00000000..21a6b980 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_initialize_disk.ps1 @@ -0,0 +1,163 @@ +#!powershell + +# Copyright: (c) 2019, Brant Evans <bevans@redhat.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.2 + +Set-StrictMode -Version 2 + +$spec = @{ + options = @{ + disk_number = @{ type = "int" } + uniqueid = @{ type = "str" } + path = @{ type = "str" } + style = @{ type = "str"; choices = "gpt", "mbr"; default = "gpt" } + online = @{ type = "bool"; default = $true } + force = @{ type = "bool"; default = $false } + } + mutually_exclusive = @( + , @('disk_number', 'uniqueid', 'path') + ) + required_one_of = @( + , @('disk_number', 'uniqueid', 'path') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$disk_number = $module.Params.disk_number +$uniqueid = $module.Params.uniqueid +$path = $module.Params.path +$partition_style = $module.Params.style +$bring_online = $module.Params.online +$force_init = $module.Params.force + +function Get-AnsibleDisk { + param( + $DiskNumber, + $UniqueId, + $Path + ) + + if ($null -ne $DiskNumber) { + try { + $disk = Get-Disk -Number $DiskNumber + } + catch { + $module.FailJson("There was an error retrieving the disk using disk_number $($DiskNumber): $($_.Exception.Message)") + } + } + elseif ($null -ne $UniqueId) { + try { + $disk = Get-Disk -UniqueId $UniqueId + } + catch { + $module.FailJson("There was an error retrieving the disk using id $($UniqueId): $($_.Exception.Message)") + } + } + elseif ($null -ne $Path) { + try { + $disk = Get-Disk -Path $Path + } + catch { + $module.FailJson("There was an error retrieving the disk using path $($Path): $($_.Exception.Message)") + } + } + else { + $module.FailJson("Unable to retrieve disk: disk_number, id, or path was not specified") + } + + return $disk +} + +function Initialize-AnsibleDisk { + param( + $AnsibleDisk, + $PartitionStyle + ) + + if ($AnsibleDisk.IsReadOnly) { + $module.FailJson("Unable to initialize disk as it is read-only") + } + + $parameters = @{ + Number = $AnsibleDisk.Number + PartitionStyle = $PartitionStyle + } + + if (-Not $module.CheckMode) { + Initialize-Disk @parameters -Confirm:$false + } + + $module.Result.changed = $true +} + +function Clear-AnsibleDisk { + param( + $AnsibleDisk + ) + + $parameters = @{ + Number = $AnsibleDisk.Number + } + + if (-Not $module.CheckMode) { + Clear-Disk @parameters -RemoveData -RemoveOEM -Confirm:$false + } +} + +function Set-AnsibleDisk { + param( + $AnsibleDisk, + $BringOnline + ) + + $refresh_disk_status = $false + + if ($BringOnline) { + if (-Not $module.CheckMode) { + if ($AnsibleDisk.IsOffline) { + Set-Disk -Number $AnsibleDisk.Number -IsOffline:$false + $refresh_disk_status = $true + } + + if ($AnsibleDisk.IsReadOnly) { + Set-Disk -Number $AnsibleDisk.Number -IsReadOnly:$false + $refresh_disk_status = $true + } + } + } + + if ($refresh_disk_status) { + $AnsibleDisk = Get-AnsibleDisk -DiskNumber $AnsibleDisk.Number + } + + return $AnsibleDisk +} + +$ansible_disk = Get-AnsibleDisk -DiskNumber $disk_number -UniqueId $uniqueid -Path $path +$ansible_part_style = $ansible_disk.PartitionStyle + +if ("RAW" -eq $ansible_part_style) { + $ansible_disk = Set-AnsibleDisk -AnsibleDisk $ansible_disk -BringOnline $bring_online + Initialize-AnsibleDisk -AnsibleDisk $ansible_disk -PartitionStyle $partition_style +} +else { + if (($ansible_part_style -ne $partition_style.ToUpper()) -And -Not $force_init) { + $msg = -join @( + "Force initialization must be specified since the target partition style: $($partition_style.ToLower()) " + "is different from the current partition style: $($ansible_part_style.ToLower())" + ) + $module.FailJson($msg) + } + elseif ($force_init) { + $ansible_disk = Set-AnsibleDisk -AnsibleDisk $ansible_disk -BringOnline $bring_online + Clear-AnsibleDisk -AnsibleDisk $ansible_disk + if ( $bring_online ) { Initialize-AnsibleDisk -AnsibleDisk $ansible_disk -PartitionStyle $partition_style } + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_initialize_disk.py b/ansible_collections/community/windows/plugins/modules/win_initialize_disk.py new file mode 100644 index 00000000..485b4a51 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_initialize_disk.py @@ -0,0 +1,75 @@ +#!/usr/bin/python + +# Copyright: (c) 2019, Brant Evans <bevans@redhat.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = ''' +--- +module: win_initialize_disk +short_description: Initializes disks on Windows Server +description: + - "The M(community.windows.win_initialize_disk) module initializes disks" +options: + disk_number: + description: + - Used to specify the disk number of the disk to be initialized. + type: int + uniqueid: + description: + - Used to specify the uniqueid of the disk to be initialized. + type: str + path: + description: + - Used to specify the path to the disk to be initialized. + type: str + style: + description: + - The partition style to use for the disk. Valid options are mbr or gpt. + type: str + choices: [ gpt, mbr ] + default: gpt + online: + description: + - If the disk is offline and/or readonly update the disk to be online and not readonly. + type: bool + default: true + force: + description: + - Specify if initializing should be forced for disks that are already initialized. + type: bool + default: no + +notes: + - One of three parameters (I(disk_number), I(uniqueid), and I(path)) are mandatory to identify the target disk, but + more than one cannot be specified at the same time. + - A minimum Operating System Version of Server 2012 or Windows 8 is required to use this module. + - This module is idempotent if I(force) is not specified. + +seealso: + - module: community.windows.win_disk_facts + - module: community.windows.win_partition + - module: community.windows.win_format + +author: + - Brant Evans (@branic) +''' + +EXAMPLES = ''' +- name: Initialize a disk + community.windows.win_initialize_disk: + disk_number: 1 + +- name: Initialize a disk with an MBR partition style + community.windows.win_initialize_disk: + disk_number: 1 + style: mbr + +- name: Forcefully initiallize a disk + community.windows.win_initialize_disk: + disk_number: 2 + force: yes +''' + +RETURN = ''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_lineinfile.ps1 b/ansible_collections/community/windows/plugins/modules/win_lineinfile.ps1 new file mode 100644 index 00000000..25af6782 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_lineinfile.ps1 @@ -0,0 +1,483 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.Backup + +function WriteLines($outlines, $path, $linesep, $encodingobj, $validate, $check_mode) { + Try { + $temppath = [System.IO.Path]::GetTempFileName() + } + Catch { + Fail-Json @{} "Cannot create temporary file! ($($_.Exception.Message))" + } + $joined = $outlines -join $linesep + [System.IO.File]::WriteAllText($temppath, $joined, $encodingobj) + + If ($validate) { + + If (-not ($validate -like "*%s*")) { + Fail-Json @{} "validate must contain %s: $validate" + } + + $validate = $validate.Replace("%s", $temppath) + + $parts = [System.Collections.ArrayList] $validate.Split(" ") + $cmdname = $parts[0] + + $cmdargs = $validate.Substring($cmdname.Length + 1) + + $process = [Diagnostics.Process]::Start($cmdname, $cmdargs) + $process.WaitForExit() + + If ($process.ExitCode -ne 0) { + [string] $output = $process.StandardOutput.ReadToEnd() + [string] $error = $process.StandardError.ReadToEnd() + Remove-Item -LiteralPath $temppath -force + Fail-Json @{} "failed to validate $cmdname $cmdargs with error: $output $error" + } + + } + + # Commit changes to the path + $cleanpath = $path.Replace("/", "\") + Try { + Copy-Item -LiteralPath $temppath -Destination $cleanpath -Force -WhatIf:$check_mode + } + Catch { + Fail-Json @{} "Cannot write to: $cleanpath ($($_.Exception.Message))" + } + + Try { + Remove-Item -LiteralPath $temppath -Force -WhatIf:$check_mode + } + Catch { + Fail-Json @{} "Cannot remove temporary file: $temppath ($($_.Exception.Message))" + } + + return $joined + +} + + +# Implement the functionality for state == 'present' +function Present { + param ( + $path, + $regex, + $line, + $insertafter, + $insertbefore, + $create, + $backup, + $backrefs, + $validate, + $encodingobj, + $linesep, + $check_mode, + $diff_support + ) + + # Note that we have to clean up the path because ansible wants to treat / and \ as + # interchangeable in windows pathnames, but .NET framework internals do not support that. + $cleanpath = $path.Replace("/", "\") + $endswithnewline = $null + + # Check if path exists. If it does not exist, either create it if create == "yes" + # was specified or fail with a reasonable error message. + If (-not (Test-Path -LiteralPath $path)) { + If (-not $create) { + Fail-Json @{} "Path $path does not exist !" + } + # Create new empty file, using the specified encoding to write correct BOM + [System.IO.File]::WriteAllLines($cleanpath, "", $encodingobj) + $endswithnewline = $false + } + + # Initialize result information + $result = @{ + backup = "" + changed = $false + msg = "" + } + + If ($insertbefore -and $insertafter) { + Add-Warning $result "Both insertbefore and insertafter parameters found, ignoring `"insertafter=$insertafter`"" + } + + # Read the dest file lines using the indicated encoding into a mutable ArrayList. + $before = [System.IO.File]::ReadAllLines($cleanpath, $encodingobj) + If ($null -eq $before) { + $lines = New-Object System.Collections.ArrayList + } + Else { + $lines = [System.Collections.ArrayList] $before + If ($null -eq $endswithnewline ) { + $alltext = [System.IO.File]::ReadAllText($cleanpath, $encodingobj) + $endswithnewline = (($alltext[-1] -eq "`n") -or ($alltext[-1] -eq "`r")) + } + } + + if ($diff_support) { + if ($endswithnewline) { + $before += "" + } + $result.diff = @{ + before = $before -join $linesep + } + } + + # Compile the regex specified, if provided + $mre = $null + If ($regex) { + $mre = New-Object Regex $regex, 'Compiled' + } + + # Compile the regex for insertafter or insertbefore, if provided + $insre = $null + If ($insertafter -and $insertafter -ne "BOF" -and $insertafter -ne "EOF") { + $insre = New-Object Regex $insertafter, 'Compiled' + } + ElseIf ($insertbefore -and $insertbefore -ne "BOF") { + $insre = New-Object Regex $insertbefore, 'Compiled' + } + + # index[0] is the line num where regex has been found + # index[1] is the line num where insertafter/insertbefore has been found + $index = -1, -1 + $lineno = 0 + + # The latest match object and matched line + $matched_line = "" + + # Iterate through the lines in the file looking for matches + Foreach ($cur_line in $lines) { + If ($regex) { + $m = $mre.Match($cur_line) + $match_found = $m.Success + If ($match_found) { + $matched_line = $cur_line + } + } + Else { + $match_found = $line -ceq $cur_line + } + If ($match_found) { + $index[0] = $lineno + } + ElseIf ($insre -and $insre.Match($cur_line).Success) { + If ($insertafter) { + $index[1] = $lineno + 1 + } + If ($insertbefore) { + $index[1] = $lineno + } + } + $lineno = $lineno + 1 + } + + If ($index[0] -ne -1) { + If ($backrefs) { + $new_line = [regex]::Replace($matched_line, $regex, $line) + } + Else { + $new_line = $line + } + If ($lines[$index[0]] -cne $new_line) { + $lines[$index[0]] = $new_line + $result.changed = $true + $result.msg = "line replaced" + } + } + ElseIf ($backrefs) { + # No matches - no-op + } + ElseIf ($insertbefore -eq "BOF" -or $insertafter -eq "BOF") { + $lines.Insert(0, $line) + $result.changed = $true + $result.msg = "line added" + } + ElseIf ($insertafter -eq "EOF" -or $index[1] -eq -1) { + $lines.Add($line) > $null + $result.changed = $true + $result.msg = "line added" + } + Else { + $lines.Insert($index[1], $line) + $result.changed = $true + $result.msg = "line added" + } + + # Write changes to the path if changes were made + If ($result.changed) { + + # Write backup file if backup == "yes" + If ($backup) { + $result.backup_file = Backup-File -path $path -WhatIf:$check_mode + # Ensure backward compatibility (deprecate in future) + $result.backup = $result.backup_file + } + + if ($endswithnewline) { + $lines.Add("") + } + + $writelines_params = @{ + outlines = $lines + path = $path + linesep = $linesep + encodingobj = $encodingobj + validate = $validate + check_mode = $check_mode + } + $after = WriteLines @writelines_params + + if ($diff_support) { + $result.diff.after = $after + } + } + + $result.encoding = $encodingobj.WebName + + Exit-Json $result +} + + +# Implement the functionality for state == 'absent' +function Absent($path, $regex, $line, $backup, $validate, $encodingobj, $linesep, $check_mode, $diff_support) { + + # Check if path exists. If it does not exist, fail with a reasonable error message. + If (-not (Test-Path -LiteralPath $path)) { + Fail-Json @{} "Path $path does not exist !" + } + + # Initialize result information + $result = @{ + backup = "" + changed = $false + msg = "" + } + + # Read the dest file lines using the indicated encoding into a mutable ArrayList. Note + # that we have to clean up the path because ansible wants to treat / and \ as + # interchangeable in windows pathnames, but .NET framework internals do not support that. + $cleanpath = $path.Replace("/", "\") + $before = [System.IO.File]::ReadAllLines($cleanpath, $encodingobj) + If ($null -eq $before) { + $lines = New-Object System.Collections.ArrayList + } + Else { + $lines = [System.Collections.ArrayList] $before + $alltext = [System.IO.File]::ReadAllText($cleanpath, $encodingobj) + If (($alltext[-1] -eq "`n") -or ($alltext[-1] -eq "`r")) { + $lines.Add("") + $before += "" + } + } + + if ($diff_support) { + $result.diff = @{ + before = $before -join $linesep + } + } + + # Compile the regex specified, if provided + $cre = $null + If ($regex) { + $cre = New-Object Regex $regex, 'Compiled' + } + + $found = New-Object System.Collections.ArrayList + $left = New-Object System.Collections.ArrayList + + Foreach ($cur_line in $lines) { + If ($regex) { + $m = $cre.Match($cur_line) + $match_found = $m.Success + } + Else { + $match_found = $line -ceq $cur_line + } + If ($match_found) { + $found.Add($cur_line) > $null + $result.changed = $true + } + Else { + $left.Add($cur_line) > $null + } + } + + # Write changes to the path if changes were made + If ($result.changed) { + + # Write backup file if backup == "yes" + If ($backup) { + $result.backup_file = Backup-File -path $path -WhatIf:$check_mode + # Ensure backward compatibility (deprecate in future) + $result.backup = $result.backup_file + } + + $writelines_params = @{ + outlines = $left + path = $path + linesep = $linesep + encodingobj = $encodingobj + validate = $validate + check_mode = $check_mode + } + $after = WriteLines @writelines_params + + if ($diff_support) { + $result.diff.after = $after + } + } + + $result.encoding = $encodingobj.WebName + $result.found = $found.Count + $result.msg = "$($found.Count) line(s) removed" + + Exit-Json $result +} + + +# Parse the parameters file dropped by the Ansible machinery +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +# Initialize defaults for input parameters. +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -aliases "dest", "destfile", "name" +$regex = Get-AnsibleParam -obj $params -name "regex" -type "str" -aliases "regexp" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent" +$line = Get-AnsibleParam -obj $params -name "line" -type "str" +$backrefs = Get-AnsibleParam -obj $params -name "backrefs" -type "bool" -default $false +$insertafter = Get-AnsibleParam -obj $params -name "insertafter" -type "str" +$insertbefore = Get-AnsibleParam -obj $params -name "insertbefore" -type "str" +$create = Get-AnsibleParam -obj $params -name "create" -type "bool" -default $false +$backup = Get-AnsibleParam -obj $params -name "backup" -type "bool" -default $false +$validate = Get-AnsibleParam -obj $params -name "validate" -type "str" +$encoding = Get-AnsibleParam -obj $params -name "encoding" -type "str" -default "auto" +$newline = Get-AnsibleParam -obj $params -name "newline" -type "str" -default "windows" -validateset "unix", "windows" + +# Fail if the path is not a file +If (Test-Path -LiteralPath $path -PathType "container") { + Fail-Json @{} "Path $path is a directory" +} + +# Default to windows line separator - probably most common +$linesep = "`r`n" +If ($newline -eq "unix") { + $linesep = "`n" +} + +# Figure out the proper encoding to use for reading / writing the target file. + +# The default encoding is UTF-8 without BOM +$encodingobj = [System.Text.UTF8Encoding] $false + +# If an explicit encoding is specified, use that instead +If ($encoding -ne "auto") { + $encodingobj = [System.Text.Encoding]::GetEncoding($encoding) +} + +# Otherwise see if we can determine the current encoding of the target file. +# If the file doesn't exist yet (create == 'yes') we use the default or +# explicitly specified encoding set above. +ElseIf (Test-Path -LiteralPath $path) { + + # Get a sorted list of encodings with preambles, longest first + $max_preamble_len = 0 + $sortedlist = New-Object System.Collections.SortedList + Foreach ($encodinginfo in [System.Text.Encoding]::GetEncodings()) { + $encoding = $encodinginfo.GetEncoding() + $plen = $encoding.GetPreamble().Length + If ($plen -gt $max_preamble_len) { + $max_preamble_len = $plen + } + If ($plen -gt 0) { + $sortedlist.Add( - ($plen * 1000000 + $encoding.CodePage), $encoding) > $null + } + } + + # Get the first N bytes from the file, where N is the max preamble length we saw + [Byte[]]$bom = Get-Content -Encoding Byte -ReadCount $max_preamble_len -TotalCount $max_preamble_len -LiteralPath $path + + # Iterate through the sorted encodings, looking for a full match. + $found = $false + Foreach ($encoding in $sortedlist.GetValueList()) { + $preamble = $encoding.GetPreamble() + If ($preamble -and $bom) { + Foreach ($i in 0..($preamble.Length - 1)) { + If ($i -ge $bom.Length) { + break + } + If ($preamble[$i] -ne $bom[$i]) { + break + } + ElseIf ($i + 1 -eq $preamble.Length) { + $encodingobj = $encoding + $found = $true + } + } + If ($found) { + break + } + } + } +} + + +# Main dispatch - based on the value of 'state', perform argument validation and +# call the appropriate handler function. +If ($state -eq "present") { + + If ($backrefs -and -not $regex) { + Fail-Json @{} "regexp= is required with backrefs=true" + } + + If (-not $line) { + Fail-Json @{} "line= is required with state=present" + } + + If (-not $insertbefore -and -not $insertafter) { + $insertafter = "EOF" + } + + $present_params = @{ + path = $path + regex = $regex + line = $line + insertafter = $insertafter + insertbefore = $insertbefore + create = $create + backup = $backup + backrefs = $backrefs + validate = $validate + encodingobj = $encodingobj + linesep = $linesep + check_mode = $check_mode + diff_support = $diff_support + } + Present @present_params + +} +ElseIf ($state -eq "absent") { + + If (-not $regex -and -not $line) { + Fail-Json @{} "one of line= or regexp= is required with state=absent" + } + + $absent_params = @{ + path = $path + regex = $regex + line = $line + backup = $backup + validate = $validate + encodingobj = $encodingobj + linesep = $linesep + check_mode = $check_mode + diff_support = $diff_support + } + Absent @absent_params +} diff --git a/ansible_collections/community/windows/plugins/modules/win_lineinfile.py b/ansible_collections/community/windows/plugins/modules/win_lineinfile.py new file mode 100644 index 00000000..64b5a7d4 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_lineinfile.py @@ -0,0 +1,171 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_lineinfile +short_description: Ensure a particular line is in a file, or replace an existing line using a back-referenced regular expression +description: + - This module will search a file for a line, and ensure that it is present or absent. + - This is primarily useful when you want to change a single line in a file only. +options: + path: + description: + - The path of the file to modify. + - Note that the Windows path delimiter C(\) must be escaped as C(\\) when the line is double quoted. + type: path + required: yes + aliases: [ dest, destfile, name ] + backup: + description: + - Determine whether a backup should be created. + - When set to C(yes), create a backup file including the timestamp information + so you can get the original file back if you somehow clobbered it incorrectly. + type: bool + default: no + regex: + description: + - The regular expression to look for in every line of the file. For C(state=present), the pattern to replace if found; only the last line found + will be replaced. For C(state=absent), the pattern of the line to remove. Uses .NET compatible regular expressions; + see U(https://msdn.microsoft.com/en-us/library/hs600312%28v=vs.110%29.aspx). + aliases: [ "regexp" ] + state: + description: + - Whether the line should be there or not. + type: str + choices: [ absent, present ] + default: present + line: + description: + - Required for C(state=present). The line to insert/replace into the file. If C(backrefs) is set, may contain backreferences that will get + expanded with the C(regexp) capture groups if the regexp matches. + - Be aware that the line is processed first on the controller and thus is dependent on yaml quoting rules. Any double quoted line + will have control characters, such as '\r\n', expanded. To print such characters literally, use single or no quotes. + type: str + backrefs: + description: + - Used with C(state=present). If set, line can contain backreferences (both positional and named) that will get populated if the C(regexp) + matches. This flag changes the operation of the module slightly; C(insertbefore) and C(insertafter) will be ignored, and if the C(regexp) + doesn't match anywhere in the file, the file will be left unchanged. + - If the C(regexp) does match, the last matching line will be replaced by the expanded line parameter. + type: bool + default: no + insertafter: + description: + - Used with C(state=present). If specified, the line will be inserted after the last match of specified regular expression. A special value is + available; C(EOF) for inserting the line at the end of the file. + - If specified regular expression has no matches, EOF will be used instead. May not be used with C(backrefs). + type: str + choices: [ EOF, '*regex*' ] + default: EOF + insertbefore: + description: + - Used with C(state=present). If specified, the line will be inserted before the last match of specified regular expression. A value is available; + C(BOF) for inserting the line at the beginning of the file. + - If specified regular expression has no matches, the line will be inserted at the end of the file. May not be used with C(backrefs). + type: str + choices: [ BOF, '*regex*' ] + create: + description: + - Used with C(state=present). If specified, the file will be created if it does not already exist. By default it will fail if the file is missing. + type: bool + default: no + validate: + description: + - Validation to run before copying into place. Use %s in the command to indicate the current file to validate. + - The command is passed securely so shell features like expansion and pipes won't work. + type: str + encoding: + description: + - Specifies the encoding of the source text file to operate on (and thus what the output encoding will be). The default of C(auto) will cause + the module to auto-detect the encoding of the source file and ensure that the modified file is written with the same encoding. + - An explicit encoding can be passed as a string that is a valid value to pass to the .NET framework System.Text.Encoding.GetEncoding() method - + see U(https://msdn.microsoft.com/en-us/library/system.text.encoding%28v=vs.110%29.aspx). + - This is mostly useful with C(create=yes) if you want to create a new file with a specific encoding. If C(create=yes) is specified without a + specific encoding, the default encoding (UTF-8, no BOM) will be used. + type: str + default: auto + newline: + description: + - Specifies the line separator style to use for the modified file. This defaults to the windows line separator (C(\r\n)). Note that the indicated + line separator will be used for file output regardless of the original line separator that appears in the input file. + type: str + choices: [ unix, windows ] + default: windows +seealso: +- module: ansible.builtin.assemble +- module: ansible.builtin.lineinfile +author: +- Brian Lloyd (@brianlloyd) +''' + +EXAMPLES = r''' +- name: Insert path without converting \r\n + community.windows.win_lineinfile: + path: c:\file.txt + line: c:\return\new + +- community.windows.win_lineinfile: + path: C:\Temp\example.conf + regex: '^name=' + line: 'name=JohnDoe' + +- community.windows.win_lineinfile: + path: C:\Temp\example.conf + regex: '^name=' + state: absent + +- community.windows.win_lineinfile: + path: C:\Temp\example.conf + regex: '^127\.0\.0\.1' + line: '127.0.0.1 localhost' + +- community.windows.win_lineinfile: + path: C:\Temp\httpd.conf + regex: '^Listen ' + insertafter: '^#Listen ' + line: Listen 8080 + +- community.windows.win_lineinfile: + path: C:\Temp\services + regex: '^# port for http' + insertbefore: '^www.*80/tcp' + line: '# port for http by default' + +- name: Create file if it doesn't exist with a specific encoding + community.windows.win_lineinfile: + path: C:\Temp\utf16.txt + create: yes + encoding: utf-16 + line: This is a utf-16 encoded file + +- name: Add a line to a file and ensure the resulting file uses unix line separators + community.windows.win_lineinfile: + path: C:\Temp\testfile.txt + line: Line added to file + newline: unix + +- name: Update a line using backrefs + community.windows.win_lineinfile: + path: C:\Temp\example.conf + backrefs: yes + regex: '(^name=)' + line: '$1JohnDoe' +''' + +RETURN = r''' +backup: + description: + - Name of the backup file that was created. + - This is now deprecated, use C(backup_file) instead. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +backup_file: + description: Name of the backup file that was created. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.ps1 b/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.ps1 new file mode 100644 index 00000000..4ad3d68d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.ps1 @@ -0,0 +1,90 @@ +#!powershell + +# Copyright: (c) 2022, DataDope (@datadope-io) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + date_format = @{ type = 'str'; default = '%c' } + tcp_filter = @{ type = 'list'; elements = 'str'; default = 'Listen' } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$date_format = $module.Params.date_format +$tcp_filter = $module.Params.tcp_filter + +# Structure of the response the script will return +$ansibleFacts = @{ + tcp_listen = @() + udp_listen = @() +} + +# Build an index of the processes based on the PID +$processes = @{} +Get-CimInstance -ClassName Win32_Process | ForEach-Object { + $processes[[int]$_.ProcessId] = $_ +} + + +# Format the given date with the same format as listen_port_facts stime (Date and time - abbreviated) by default, or +# with the given format +function Format-Date { + param ( + $date + ) + + if ($null -ne $date) { + $date = Get-Date $date -UFormat $date_format + } + + return $date +} + +# Return the processed listener and the associated PID data +function Build-Listener { + param ( + $listener, + $type + ) + + $process = $processes[[int]$listener.OwningProcess] + $process_owner = Invoke-CimMethod -InputObject $process -MethodName GetOwner + + $owner = $null + if ($null -ne $process_owner.User -and $null -ne $process_owner.Domain) { + $owner = $process_owner.Domain + '\' + $process_owner.User + } + + return @{ + address = $listener.LocalAddress + name = $process.Name + pid = $listener.OwningProcess + port = $listener.LocalPort + protocol = $type + stime = Format-Date $process.CreationDate + user = $owner + } +} + +try { + # Retrieve the information of the TCP ports with Listen status by default, or with the given state/s + Get-NetTCPConnection -State $tcp_filter -ErrorAction SilentlyContinue | Foreach-Object { + $ansibleFacts.tcp_listen += Build-Listener $_ "tcp" + } + + # Retrieve the information of the UDP ports + Get-NetUDPEndpoint | Foreach-Object { + $ansibleFacts.udp_listen += Build-Listener $_ "udp" + } +} +catch { + $module.FailJson("An error occurred while retrieving ports facts: $($_.Exception.Message)", $_) +} + +$module.Result.ansible_facts = $ansibleFacts +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.py b/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.py new file mode 100644 index 00000000..89d51be8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_listen_ports_facts.py @@ -0,0 +1,95 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, DataDope (@datadope-io) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_listen_ports_facts +version_added: '1.10.0' +short_description: Recopilates the facts of the listening ports of the machine +description: + - Recopilates the information of the TCP and UDP ports of the machine and + the related processes. + - State of the TCP ports could be filtered, as well as the format of the + date when the parent process was launched. + - The module's goal is to replicate the functionality of the linux module + listen_ports_facts, mantaining the format of the said module. +options: + date_format: + description: + - The format of the date when the process that owns the port started. + - The date specification is UFormat + type: str + default: '%c' + tcp_filter: + description: + - Filter for the state of the TCP ports that will be recopilated. + - Supports multiple states (Bound, Closed, CloseWait, Closing, DeleteTCB, + Established, FinWait1, FinWait2, LastAck, Listen, SynReceived, SynSent + and TimeWait), that can be used alone or combined. Note that the Bound + state is only available on PowerShell version 4.0 or later. + type: list + elements: str + default: [ Listen ] +notes: +- The generated data (tcp_listen and udp_listen) and the fields within follows + the listen_ports_facts schema to achieve compatibility with the said module + output, even though this module if capable of extracting ports with a state + other than Listen +seealso: +- module: community.general.listen_ports_facts +author: +- David Nieto (@david-ns) +''' + +EXAMPLES = r''' +- name: Recopilate ports facts + community.windows.win_listen_ports_facts: + +- name: Retrieve only ports with Closing and Established states + community.windows.win_listen_ports_facts: + tcp_filter: + - Closing + - Established + +- name: Get ports facts with only the year within the date field + community.windows.win_listen_ports_facts: + date_format: '%Y' +''' + +RETURN = r''' +tcp_listen: + description: List of dicts with the detected TCP ports + returned: success + type: list + elements: dict + sample: [ + { + "address": "127.0.0.1", + "name": "python", + "pid": 5332, + "port": 82, + "protocol": "tcp", + "stime": "Thu Nov 18 15:27:42 2021", + "user": "SERVER\\Administrator" + } + ] +udp_listen: + description: List of dicts with the detected UDP ports + returned: success + type: list + elements: dict + sample: [ + { + "address": "127.0.0.1", + "name": "python", + "pid": 5332, + "port": 82, + "protocol": "udp", + "stime": "Thu Nov 18 15:27:42 2021", + "user": "SERVER\\Administrator" + } + ] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_mapped_drive.ps1 b/ansible_collections/community/windows/plugins/modules/win_mapped_drive.ps1 new file mode 100644 index 00000000..210ae223 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_mapped_drive.ps1 @@ -0,0 +1,449 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.AccessToken +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + letter = @{ type = "str"; required = $true } + path = @{ type = "path"; } + state = @{ type = "str"; default = "present"; choices = @("absent", "present") } + username = @{ type = "str" } + password = @{ type = "str"; no_log = $true } + } + required_if = @( + , @("state", "present", @("path")) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$letter = $module.Params.letter +$path = $module.Params.path +$state = $module.Params.state +$username = $module.Params.username +$password = $module.Params.password + +if ($letter -notmatch "^[a-zA-z]{1}$") { + $module.FailJson("letter must be a single letter from A-Z, was: $letter") +} +$letter_root = "$($letter):" + +$module.Diff.before = "" +$module.Diff.after = "" + +Add-CSharpType -AnsibleModule $module -References @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace Ansible.MappedDrive +{ + internal class NativeHelpers + { + public enum ResourceScope : uint + { + Connected = 0x00000001, + GlobalNet = 0x00000002, + Remembered = 0x00000003, + Recent = 0x00000004, + Context = 0x00000005, + } + + [Flags] + public enum ResourceType : uint + { + Any = 0x0000000, + Disk = 0x00000001, + Print = 0x00000002, + Reserved = 0x00000008, + Unknown = 0xFFFFFFFF, + } + + public enum CloseFlags : uint + { + None = 0x00000000, + UpdateProfile = 0x00000001, + } + + [Flags] + public enum AddFlags : uint + { + UpdateProfile = 0x00000001, + UpdateRecent = 0x00000002, + Temporary = 0x00000004, + Interactive = 0x00000008, + Prompt = 0x00000010, + Redirect = 0x00000080, + CurrentMedia = 0x00000200, + CommandLine = 0x00000800, + CmdSaveCred = 0x00001000, + CredReset = 0x00002000, + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct NETRESOURCEW + { + public ResourceScope dwScope; + public ResourceType dwType; + public UInt32 dwDisplayType; + public UInt32 dwUsage; + [MarshalAs(UnmanagedType.LPWStr)] public string lpLocalName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpRemoteName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpComment; + [MarshalAs(UnmanagedType.LPWStr)] public string lpProvider; + } + } + + internal class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle( + IntPtr hObject); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool ImpersonateLoggedOnUser( + IntPtr hToken); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool RevertToSelf(); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetAddConnection2W( + NativeHelpers.NETRESOURCEW lpNetResource, + [MarshalAs(UnmanagedType.LPWStr)] string lpPassword, + [MarshalAs(UnmanagedType.LPWStr)] string lpUserName, + NativeHelpers.AddFlags dwFlags); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetCancelConnection2W( + [MarshalAs(UnmanagedType.LPWStr)] string lpName, + NativeHelpers.CloseFlags dwFlags, + bool fForce); + + [DllImport("Mpr.dll")] + public static extern UInt32 WNetCloseEnum( + IntPtr hEnum); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetEnumResourceW( + IntPtr hEnum, + ref Int32 lpcCount, + SafeMemoryBuffer lpBuffer, + ref UInt32 lpBufferSize); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetOpenEnumW( + NativeHelpers.ResourceScope dwScope, + NativeHelpers.ResourceType dwType, + UInt32 dwUsage, + IntPtr lpNetResource, + out IntPtr lphEnum); + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + internal class Impersonation : IDisposable + { + private IntPtr hToken = IntPtr.Zero; + + public Impersonation(IntPtr token) + { + hToken = token; + if (token != IntPtr.Zero) + if (!NativeMethods.ImpersonateLoggedOnUser(hToken)) + throw new Win32Exception("Failed to impersonate token with ImpersonateLoggedOnUser()"); + } + + public void Dispose() + { + if (hToken != null) + NativeMethods.RevertToSelf(); + GC.SuppressFinalize(this); + } + ~Impersonation() { Dispose(); } + } + + public class DriveInfo + { + public string Drive; + public string Path; + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class Utils + { + private const UInt32 ERROR_SUCCESS = 0x00000000; + private const UInt32 ERROR_NO_MORE_ITEMS = 0x0000103; + + public static void AddMappedDrive(string drive, string path, IntPtr iToken, string username = null, string password = null) + { + NativeHelpers.NETRESOURCEW resource = new NativeHelpers.NETRESOURCEW + { + dwType = NativeHelpers.ResourceType.Disk, + lpLocalName = drive, + lpRemoteName = path, + }; + NativeHelpers.AddFlags dwFlags = NativeHelpers.AddFlags.UpdateProfile; + // While WNetAddConnection2W supports user/pass, this is only used for the first connection and the + // password is not remembered. We will delete the username mapping afterwards as it interferes with + // the implicit credential cache used in Windows + using (Impersonation imp = new Impersonation(iToken)) + { + UInt32 res = NativeMethods.WNetAddConnection2W(resource, password, username, dwFlags); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, String.Format("Failed to map {0} to '{1}' with WNetAddConnection2W()", drive, path)); + } + } + + public static List<DriveInfo> GetMappedDrives(IntPtr iToken) + { + using (Impersonation imp = new Impersonation(iToken)) + { + IntPtr enumPtr = IntPtr.Zero; + UInt32 res = NativeMethods.WNetOpenEnumW(NativeHelpers.ResourceScope.Remembered, NativeHelpers.ResourceType.Disk, + 0, IntPtr.Zero, out enumPtr); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, "WNetOpenEnumW()"); + + List<DriveInfo> resources = new List<DriveInfo>(); + try + { + // MS recommend a buffer size of 16 KiB + UInt32 bufferSize = 16384; + int lpcCount = -1; + + // keep iterating the enum until ERROR_NO_MORE_ITEMS is returned + do + { + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer((int)bufferSize)) + { + res = NativeMethods.WNetEnumResourceW(enumPtr, ref lpcCount, buffer, ref bufferSize); + if (res == ERROR_NO_MORE_ITEMS) + continue; + else if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, "WNetEnumResourceW()"); + lpcCount = lpcCount < 0 ? 0 : lpcCount; + + NativeHelpers.NETRESOURCEW[] rawResources = new NativeHelpers.NETRESOURCEW[lpcCount]; + PtrToStructureArray(rawResources, buffer.DangerousGetHandle()); + foreach (NativeHelpers.NETRESOURCEW resource in rawResources) + { + DriveInfo currentDrive = new DriveInfo + { + Drive = resource.lpLocalName, + Path = resource.lpRemoteName, + }; + resources.Add(currentDrive); + } + } + } + while (res != ERROR_NO_MORE_ITEMS); + } + finally + { + NativeMethods.WNetCloseEnum(enumPtr); + } + + return resources; + } + } + + public static void RemoveMappedDrive(string drive, IntPtr iToken) + { + using (Impersonation imp = new Impersonation(iToken)) + { + UInt32 res = NativeMethods.WNetCancelConnection2W(drive, NativeHelpers.CloseFlags.UpdateProfile, true); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, String.Format("Failed to remove mapped drive {0} with WNetCancelConnection2W()", drive)); + } + } + + private static void PtrToStructureArray<T>(T[] array, IntPtr ptr) + { + IntPtr ptrOffset = ptr; + for (int i = 0; i < array.Length; i++, ptrOffset = IntPtr.Add(ptrOffset, Marshal.SizeOf(typeof(T)))) + array[i] = (T)Marshal.PtrToStructure(ptrOffset, typeof(T)); + } + } +} +'@ + +Function Get-LimitedToken { + $h_process = [Ansible.AccessToken.TokenUtil]::OpenProcess() + $h_token = [Ansible.AccessToken.TokenUtil]::OpenProcessToken($h_process, "Duplicate, Query") + + try { + # If we don't have a Full token, we don't need to get the limited one to set a mapped drive + $tet = [Ansible.AccessToken.TokenUtil]::GetTokenElevationType($h_token) + if ($tet -ne [Ansible.AccessToken.TokenElevationType]::Full) { + return + } + + foreach ($system_token in [Ansible.AccessToken.TokenUtil]::EnumerateUserTokens("S-1-5-18", "Duplicate")) { + # To get the TokenLinkedToken we need the SeTcbPrivilege, not all SYSTEM tokens have this assigned so + # we need to check before impersonating that token + $token_privileges = [Ansible.AccessToken.TokenUtil]::GetTokenPrivileges($system_token) + if ($null -eq ($token_privileges | Where-Object { $_.Name -eq "SeTcbPrivilege" })) { + continue + } + + [Ansible.AccessToken.TokenUtil]::ImpersonateToken($system_token) + try { + return [Ansible.AccessToken.TokenUtil]::GetTokenLinkedToken($h_token) + } + finally { + [Ansible.AccessToken.TokenUtil]::RevertToSelf() + } + } + } + finally { + $h_token.Dispose() + } +} + +<# +When we run with become and UAC is enabled, the become process will most likely be the Admin/Full token. This is +an issue with the WNetConnection APIs as the Full token is unable to add/enumerate/remove connections due to +Windows storing the connection details on each token session ID. Unless EnabledLinkedConnections (reg key) is +set to 1, the Full token is unable to manage connections in a persisted way whereas the Limited token is. This +is similar to running 'net use' normally and an admin process is unable to see those and vice versa. + +To overcome this problem, we attempt to get a handle on the Limited token for the current logon and impersonate +that before making any WNetConnection calls. If the token is not split, or we are already running on the Limited +token then no impersonatoin is used/required. This allows the module to run with become (required to access the +credential store) but still be able to manage the mapped connections. + +These are the following scenarios we have to handle; + + 1. Run without become + A network logon is usually not split so GetLimitedToken() will return $null and no impersonation is needed + 2. Run with become on admin user with admin priv + We will have a Full token, GetLimitedToken() will return the limited token and impersonation is used + 3. Run with become on admin user without admin priv + We are already running with a Limited token, GetLimitedToken() return $nul and no impersonation is needed + 4. Run with become on standard user + There's no split token, GetLimitedToken() will return $null and no impersonation is needed +#> +$impersonation_token = Get-LimitedToken + +try { + $i_token_ptr = [System.IntPtr]::Zero + if ($null -ne $impersonation_token) { + $i_token_ptr = $impersonation_token.DangerousGetHandle() + } + + $existing_targets = [Ansible.MappedDrive.Utils]::GetMappedDrives($i_token_ptr) + $existing_target = $existing_targets | Where-Object { $_.Drive -eq $letter_root } + + if ($existing_target) { + $module.Diff.before = @{ + letter = $letter + path = $existing_target.Path + } + } + + if ($state -eq "absent") { + if ($null -ne $existing_target) { + if ($null -ne $path -and $existing_target.Path -ne $path) { + $module.FailJson("did not delete mapped drive $letter, the target path is pointing to a different location at $( $existing_target.Path )") + } + if (-not $module.CheckMode) { + [Ansible.MappedDrive.Utils]::RemoveMappedDrive($letter_root, $i_token_ptr) + } + + $module.Result.changed = $true + } + } + else { + $physical_drives = Get-PSDrive -PSProvider "FileSystem" + if ($letter -in $physical_drives.Name) { + $module.FailJson("failed to create mapped drive $letter, this letter is in use and is pointing to a non UNC path") + } + + # PowerShell converts a $null value to "" when crossing the .NET marshaler, we need to convert the input + # to a missing value so it uses the defaults. We also need to Invoke it with MethodInfo.Invoke so the defaults + # are still used + $input_username = $username + if ($null -eq $username) { + $input_username = [Type]::Missing + } + $input_password = $password + if ($null -eq $password) { + $input_password = [Type]::Missing + } + $add_method = [Ansible.MappedDrive.Utils].GetMethod("AddMappedDrive") + + if ($null -ne $existing_target) { + if ($existing_target.Path -ne $path) { + if (-not $module.CheckMode) { + [Ansible.MappedDrive.Utils]::RemoveMappedDrive($letter_root, $i_token_ptr) + $add_method.Invoke($null, [Object[]]@($letter_root, $path, $i_token_ptr, $input_username, $input_password)) + } + $module.Result.changed = $true + } + } + else { + if (-not $module.CheckMode) { + $add_method.Invoke($null, [Object[]]@($letter_root, $path, $i_token_ptr, $input_username, $input_password)) + } + + $module.Result.changed = $true + } + + # If username was set and we made a change, remove the UserName value so Windows will continue to use the cred + # cache. If we don't do this then the drive will fail to map in the future as WNetAddConnection does not cache + # the password and relies on the credential store. + if ($null -ne $username -and $module.Result.changed -and -not $module.CheckMode) { + Set-ItemProperty -LiteralPath HKCU:\Network\$letter -Name UserName -Value "" -WhatIf:$module.CheckMode + } + + $module.Diff.after = @{ + letter = $letter + path = $path + } + } +} +finally { + if ($null -ne $impersonation_token) { + $impersonation_token.Dispose() + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_mapped_drive.py b/ansible_collections/community/windows/plugins/modules/win_mapped_drive.py new file mode 100644 index 00000000..2762e2b2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_mapped_drive.py @@ -0,0 +1,147 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_mapped_drive +short_description: Map network drives for users +description: +- Allows you to modify mapped network drives for individual users. +- Also support WebDAV endpoints in the UNC form. +options: + letter: + description: + - The letter of the network path to map to. + - This letter must not already be in use with Windows. + type: str + required: yes + password: + description: + - The password for C(username) that is used when testing the initial + connection. + - This is never saved with a mapped drive, use the M(community.windows.win_credential) module + to persist a username and password for a host. + type: str + path: + description: + - The UNC path to map the drive to. + - If pointing to a WebDAV location this must still be in a UNC path in the + format C(\\hostname\path) and not a URL, see examples for more details. + - To specify a C(https) WebDAV path, add C(@SSL) after the hostname. To + specify a custom WebDAV port add C(@<port num>) after the C(@SSL) or + hostname portion of the UNC path, e.g. C(\\server@SSL@1234) or + C(\\server@1234). + - This is required if C(state=present). + - If C(state=absent) and I(path) is not set, the module will delete the + mapped drive regardless of the target. + - If C(state=absent) and the I(path) is set, the module will throw an error + if path does not match the target of the mapped drive. + type: path + state: + description: + - If C(present) will ensure the mapped drive exists. + - If C(absent) will ensure the mapped drive does not exist. + type: str + choices: [ absent, present ] + default: present + username: + description: + - The username that is used when testing the initial connection. + - This is never saved with a mapped drive, the M(community.windows.win_credential) module + to persist a username and password for a host. + - This is required if the mapped drive requires authentication with + custom credentials and become, or CredSSP cannot be used. + - If become or CredSSP is used, any credentials saved with + M(community.windows.win_credential) will automatically be used instead. + type: str +notes: +- You cannot use this module to access a mapped drive in another Ansible task, + drives mapped with this module are only accessible when logging in + interactively with the user through the console or RDP. +- It is recommend to run this module with become or CredSSP when the remote + path requires authentication. +- When using become or CredSSP, the task will have access to any local + credentials stored in the user's vault. +- If become or CredSSP is not available, the I(username) and I(password) + options can be used for the initial authentication but these are not + persisted. +- WebDAV paths must have the WebDAV client feature installed for this module to + map those paths. This is installed by default on desktop Windows editions but + Windows Server hosts need to install the C(WebDAV-Redirector) feature using + M(ansible.windows.win_feature). +seealso: +- module: community.windows.win_credential +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create a mapped drive under Z + community.windows.win_mapped_drive: + letter: Z + path: \\domain\appdata\accounting + +- name: Delete any mapped drives under Z + community.windows.win_mapped_drive: + letter: Z + state: absent + +- name: Only delete the mapped drive Z if the paths match (error is thrown otherwise) + community.windows.win_mapped_drive: + letter: Z + path: \\domain\appdata\accounting + state: absent + +- name: Create mapped drive with credentials and save the username and password + block: + - name: Save the network credentials required for the mapped drive + community.windows.win_credential: + name: server + type: domain_password + username: username@DOMAIN + secret: Password01 + state: present + + - name: Create a mapped drive that requires authentication + community.windows.win_mapped_drive: + letter: M + path: \\SERVER\C$ + state: present + vars: + # become is required to save and retrieve the credentials in the tasks + ansible_become: yes + ansible_become_method: runas + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + +- name: Create mapped drive with credentials that do not persist on the next logon + community.windows.win_mapped_drive: + letter: M + path: \\SERVER\C$ + state: present + username: '{{ ansible_user }}' + password: '{{ ansible_password }}' + +# This should only be required for Windows Server OS' +- name: Ensure WebDAV client feature is installed + ansible.windows.win_feature: + name: WebDAV-Redirector + state: present + register: webdav_feature + +- name: Reboot after installing WebDAV client feature + ansible.windows.win_reboot: + when: webdav_feature.reboot_required + +- name: Map the HTTPS WebDAV location + community.windows.win_mapped_drive: + letter: W + path: \\live.sysinternals.com@SSL\tools # https://live.sysinternals.com/tools + state: present +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_msg.ps1 b/ansible_collections/community/windows/plugins/modules/win_msg.ps1 new file mode 100644 index 00000000..91763886 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_msg.ps1 @@ -0,0 +1,52 @@ +#!powershell + +# Copyright: (c) 2016, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +# +$stopwatch = [system.diagnostics.stopwatch]::startNew() + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$display_seconds = Get-AnsibleParam -obj $params -name "display_seconds" -type "int" -default "10" +$msg = Get-AnsibleParam -obj $params -name "msg" -type "str" -default "Hello world!" +$to = Get-AnsibleParam -obj $params -name "to" -type "str" -default "*" +$wait = Get-AnsibleParam -obj $params -name "wait" -type "bool" -default $false + +$result = @{ + changed = $false + display_seconds = $display_seconds + msg = $msg + wait = $wait +} + +if ($msg.Length -gt 255) { + Fail-Json -obj $result -message "msg length must be less than 256 characters, current length: $($msg.Length)" +} + +$msg_args = @($to, "/TIME:$display_seconds") + +if ($wait) { + $msg_args += "/W" +} + +$msg_args += $msg +if (-not $check_mode) { + $output = & msg.exe $msg_args 2>&1 + $result.rc = $LASTEXITCODE +} + +$endsend_at = Get-Date | Out-String +$stopwatch.Stop() + +$result.changed = $true +$result.runtime_seconds = $stopwatch.Elapsed.TotalSeconds +$result.sent_localtime = $endsend_at.Trim() + +if ($result.rc -ne 0 ) { + Fail-Json -obj $result -message "$output" +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_msg.py b/ansible_collections/community/windows/plugins/modules/win_msg.py new file mode 100644 index 00000000..88a8beb1 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_msg.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_msg +short_description: Sends a message to logged in users on Windows hosts +description: + - Wraps the msg.exe command in order to send messages to Windows hosts. +options: + to: + description: + - Who to send the message to. Can be a username, sessionname or sessionid. + type: str + default: '*' + display_seconds: + description: + - How long to wait for receiver to acknowledge message, in seconds. + type: int + default: 10 + wait: + description: + - Whether to wait for users to respond. Module will only wait for the number of seconds specified in display_seconds or 10 seconds if not specified. + However, if I(wait) is C(yes), the message is sent to each logged on user in turn, waiting for the user to either press 'ok' or for + the timeout to elapse before moving on to the next user. + type: bool + default: 'no' + msg: + description: + - The text of the message to be displayed. + - The message must be less than 256 characters. + type: str + default: Hello world! +notes: + - This module must run on a windows host, so ensure your play targets windows + hosts, or delegates to a windows host. + - Messages are only sent to the local host where the module is run. + - The module does not support sending to users listed in a file. + - Setting wait to C(yes) can result in long run times on systems with many logged in users. +seealso: +- module: community.windows.win_say +- module: community.windows.win_toast +author: +- Jon Hawkesworth (@jhawkesworth) +''' + +EXAMPLES = r''' +- name: Warn logged in users of impending upgrade + community.windows.win_msg: + display_seconds: 60 + msg: Automated upgrade about to start. Please save your work and log off before {{ deployment_start_time }} +''' + +RETURN = r''' +msg: + description: Test of the message that was sent. + returned: changed + type: str + sample: Automated upgrade about to start. Please save your work and log off before 22 July 2016 18:00:00 +display_seconds: + description: Value of display_seconds module parameter. + returned: success + type: str + sample: 10 +rc: + description: The return code of the API call. + returned: always + type: int + sample: 0 +runtime_seconds: + description: How long the module took to run on the remote windows host. + returned: success + type: str + sample: 22 July 2016 17:45:51 +sent_localtime: + description: local time from windows host when the message was sent. + returned: success + type: str + sample: 22 July 2016 17:45:51 +wait: + description: Value of wait module parameter. + returned: success + type: bool + sample: false +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.ps1 b/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.ps1 new file mode 100644 index 00000000..a7377e16 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.ps1 @@ -0,0 +1,67 @@ +#!powershell + +# Copyright: (c) 2020, ライトウェルの人 <jiro.higuchi@shi-g.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + interface = @{ type = 'list'; elements = 'str'; required = $true } + state = @{ type = 'str'; choices = 'disabled', 'enabled'; default = 'enabled' } + component_id = @{ type = 'list'; elements = 'str'; required = $true } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$interface = $module.Params.interface +$state = $module.Params.state +$component_id = $module.Params.component_id +$check_mode = $module.CheckMode + +If ($interface -eq "*") { + $interface = Get-NetAdapter | Select-Object -ExpandProperty Name +} +Else { + ForEach ($Interface_name in $interface) { + If (@(Get-NetAdapter | Where-Object Name -eq $Interface_name).Count -eq 0) { + $module.FailJson("Invalid network adapter name: $Interface_name") + } + } +} + +$state = $state -eq "enabled" + +ForEach ($componentID_name in $component_id) { + If (@(Get-NetAdapterBinding | Where-Object ComponentID -eq $componentID_name).Count -eq 0) { + $module.FailJson("Invalid componentID: $componentID_name") + } +} + +$module.Result.changed = $false + +ForEach ($componentID_name in $component_id) { + ForEach ($Interface_name in $interface) { + $current_state = (Get-NetAdapterBinding | where-object { $_.Name -eq $Interface_name } | where-object { $_.ComponentID -eq $componentID_name }).Enabled + #Initialize the check_Idempotency flag for each interface, and for each component_id. + $check_Idempotency = $true + + If ($current_state -eq $state) { + $check_Idempotency = $false + } + + #Even Once $check_Idempotency remains $true, $module.Result.changed turns $true. + $module.Result.changed = $module.Result.changed -Or $check_Idempotency + + If ($check_Idempotency) { + If ($state -eq "True") { + Enable-NetAdapterBinding -Name $Interface_name -ComponentID $componentID_name -WhatIf:$check_mode + } + Else { + Disable-NetAdapterBinding -Name $Interface_name -ComponentID $componentID_name -WhatIf:$check_mode + } + } + } +} +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.py b/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.py new file mode 100644 index 00000000..91bb40fe --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_net_adapter_feature.py @@ -0,0 +1,69 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, ライトウェルの人 <jiro.higuchi@shi-g.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +DOCUMENTATION = r''' +--- +module: win_net_adapter_feature +version_added: 1.2.0 +short_description: Enable or disable certain network adapters. +description: + - Enable or disable some network components of a certain network adapter or all the network adapters. +options: + interface: + description: + - Name of Network Adapter Interface. For example, C(Ethernet0) or C(*). + type: list + elements: str + required: yes + state: + description: + - Specify the state of ms_tcpip6 of interfaces. + type: str + choices: [ enabled, disabled ] + default: enabled + required: no + component_id: + description: + - Specify the below component_id of network adapters. + - component_id (DisplayName) + - C(ms_implat) (Microsoft Network Adapter Multiplexor Protocol) + - C(ms_lltdio) (Link-Layer Topology Discovery Mapper I/O Driver) + - C(ms_tcpip6) (Internet Protocol Version 6 (TCP/IPv6)) + - C(ms_tcpip) (Internet Protocol Version 4 (TCP/IPv4)) + - C(ms_lldp) (Microsoft LLDP Protocol Driver) + - C(ms_rspndr) (Link-Layer Topology Discovery Responder) + - C(ms_msclient) (Client for Microsoft Networks) + - C(ms_pacer) (QoS Packet Scheduler) + - If you'd like to set custom adapters like 'Juniper Network Service', get the I(component_id) by running the C(Get-NetAdapterBinding) cmdlet. + type: list + elements: str + required: yes + +author: + - ライトウェルの人 (@jirolin) +''' + + +EXAMPLES = r''' +- name: enable multiple interfaces of multiple interfaces + community.windows.win_net_adapter_feature: + interface: + - 'Ethernet0' + - 'Ethernet1' + state: enabled + component_id: + - ms_tcpip6 + - ms_server + +- name: Enable ms_tcpip6 of all the Interface + community.windows.win_net_adapter_feature: + interface: '*' + state: enabled + component_id: + - ms_tcpip6 + +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_netbios.ps1 b/ansible_collections/community/windows/plugins/modules/win_netbios.ps1 new file mode 100644 index 00000000..5995e580 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_netbios.ps1 @@ -0,0 +1,70 @@ +#!powershell + +# Copyright: (c) 2019, Thomas Moore (@tmmruk) <hi@tmmr.uk> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + state = @{ type = "str"; choices = "enabled", "disabled", "default"; required = $true } + adapter_names = @{ type = "list"; elements = "str"; required = $false } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$module.Result.reboot_required = $false + +$state = $module.Params.state +$adapter_names = $module.Params.adapter_names + +switch ( $state ) { + 'default' { $netbiosoption = 0 } + enabled { $netbiosoption = 1 } + disabled { $netbiosoption = 2 } +} + +if (-not $adapter_names) { + # Target all network adapters on the system + $get_params = @{ + ClassName = 'Win32_NetworkAdapterConfiguration' + Filter = 'IPEnabled=true' + Property = @('MacAddress', 'TcpipNetbiosOptions') + } + $target_adapters_config = Get-CimInstance @get_params +} +else { + $get_params = @{ + Class = 'Win32_NetworkAdapter' + Filter = ($adapter_names | ForEach-Object -Process { "NetConnectionId='$_'" }) -join " OR " + KeyOnly = $true + } + $target_adapters_config = Get-CimInstance @get_params | Get-CimAssociatedInstance -ResultClass 'Win32_NetworkAdapterConfiguration' + if (($target_adapters_config | Measure-Object).Count -ne $adapter_names.Count) { + $module.FailJson("Not all of the target adapter names could be found on the system. No configuration changes have been made. $adapter_names") + } +} + +foreach ($adapter in $target_adapters_config) { + if ($adapter.TcpipNetbiosOptions -ne $netbiosoption) { + if (-not $module.CheckMode) { + $result = Invoke-CimMethod -InputObject $adapter -MethodName SetTcpipNetbios -Arguments @{TcpipNetbiosOptions = $netbiosoption } + switch ( $result.ReturnValue ) { + 0 { <# Success no reboot required #> } + 1 { $module.Result.reboot_required = $true } + 100 { + $msg = "DHCP not enabled on adapter $($adapter.MacAddress). Unable to set default. Try using disabled or enabled options instead." + $module.Warn($msg) + } + default { + $msg = "An error occurred while setting TcpipNetbios options on adapter $($adapter.MacAddress). Return code $($result.ReturnValue)." + $module.FailJson($msg) + } + } + } + $module.Result.changed = $true + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_netbios.py b/ansible_collections/community/windows/plugins/modules/win_netbios.py new file mode 100644 index 00000000..80e0fbec --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_netbios.py @@ -0,0 +1,69 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Thomas Moore (@tmmruk) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_netbios +short_description: Manage NetBIOS over TCP/IP settings on Windows. +description: + - Enables or disables NetBIOS on Windows network adapters. + - Can be used to protect a system against NBT-NS poisoning and avoid NBNS broadcast storms. + - Settings can be applied system wide or per adapter. +options: + state: + description: + - Whether NetBIOS should be enabled, disabled, or default (use setting from DHCP server or if static IP address is assigned enable NetBIOS). + choices: + - enabled + - disabled + - default + required: yes + type: str + adapter_names: + description: + - List of adapter names for which to manage NetBIOS settings. If this option is omitted then configuration is applied to all adapters on the system. + - The adapter name used is the connection caption in the Network Control Panel or via C(Get-NetAdapter), eg C(Ethernet 2). + type: list + elements: str + required: no + +author: + - Thomas Moore (@tmmruk) +notes: + - Changing NetBIOS settings does not usually require a reboot and will take effect immediately. + - UDP port 137/138/139 will no longer be listening once NetBIOS is disabled. +''' + +EXAMPLES = r''' +- name: Disable NetBIOS system wide + community.windows.win_netbios: + state: disabled + +- name: Disable NetBIOS on Ethernet2 + community.windows.win_netbios: + state: disabled + adapter_names: + - Ethernet2 + +- name: Enable NetBIOS on Public and Backup adapters + community.windows.win_netbios: + state: enabled + adapter_names: + - Public + - Backup + +- name: Set NetBIOS to system default on all adapters + community.windows.win_netbios: + state: default +''' + +RETURN = r''' +reboot_required: + description: Boolean value stating whether a system reboot is required. + returned: always + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_nssm.ps1 b/ansible_collections/community/windows/plugins/modules/win_nssm.ps1 new file mode 100644 index 00000000..3b951cf1 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_nssm.ps1 @@ -0,0 +1,604 @@ +#!powershell + +# Copyright: (c) 2015, George Frank <george@georgefrank.net> +# Copyright: (c) 2015, Adam Keech <akeech@chathamfinancial.com> +# Copyright: (c) 2015, Hans-Joachim Kliemeck <git@kliemeck.de> +# Copyright: (c) 2019, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$ErrorActionPreference = "Stop" + +$start_modes_map = @{ + "auto" = "SERVICE_AUTO_START" + "delayed" = "SERVICE_DELAYED_AUTO_START" + "manual" = "SERVICE_DEMAND_START" + "disabled" = "SERVICE_DISABLED" +} + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$state_options = "present", "absent", "started", "stopped", "restarted" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset $state_options -resultobj $result +$display_name = Get-AnsibleParam -obj $params -name 'display_name' -type 'str' +$description = Get-AnsibleParam -obj $params -name 'description' -type 'str' + +$application = Get-AnsibleParam -obj $params -name "application" -type "path" +$appDirectory = Get-AnsibleParam -obj $params -name "working_directory" -aliases "app_directory", "chdir" -type "path" +$appParameters = Get-AnsibleParam -obj $params -name "app_parameters" +$appArguments = Get-AnsibleParam -obj $params -name "arguments" -aliases "app_parameters_free_form" + +$stdoutFile = Get-AnsibleParam -obj $params -name "stdout_file" -type "path" +$stderrFile = Get-AnsibleParam -obj $params -name "stderr_file" -type "path" + +$executable = Get-AnsibleParam -obj $params -name "executable" -type "path" -default "nssm.exe" + +$app_env = Get-AnsibleParam -obj $params -name "app_environment" -type "dict" + +$app_rotate_bytes = Get-AnsibleParam -obj $params -name "app_rotate_bytes" -type "int" -default 104858 +$app_rotate_online = Get-AnsibleParam -obj $params -name "app_rotate_online" -type "int" -default 0 -validateset 0, 1 +$app_stop_method_console = Get-AnsibleParam -obj $params -name "app_stop_method_console" -type "int" +$app_stop_method_skip = Get-AnsibleParam -obj $params -name "app_stop_method_skip" -type "int" -validateset 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 + +# Deprecated options, will be removed in a major release after 2021-07-01. +$startMode = Get-AnsibleParam -obj $params -name "start_mode" -type "str" -default "auto" -validateset $start_modes_map.Keys -resultobj $result +$dependencies = Get-AnsibleParam -obj $params -name "dependencies" -type "list" +$user = Get-AnsibleParam -obj $params -name "username" -type "str" -aliases "user" +$password = Get-AnsibleParam -obj $params -name "password" -type "str" + +$result = @{ + changed = $false +} +$diff_text = $null + +function Invoke-NssmCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [string[]]$arguments + ) + + $command = Argv-ToString -arguments (@($executable) + $arguments) + $result = Run-Command -command $command + + $result.arguments = $command + + return $result +} + +function Get-NssmServiceStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + return Invoke-NssmCommand -arguments @("status", $service) +} + +function Get-NssmServiceParameter { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service, + [Parameter(Mandatory = $true)] + [Alias("param")] + [string]$parameter, + [Parameter(Mandatory = $false)] + [string]$subparameter + ) + + $arguments = @("get", $service, $parameter) + if ($subparameter -ne "") { + $arguments += $subparameter + } + return Invoke-NssmCommand -arguments $arguments +} + +function Set-NssmServiceParameter { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service, + [Parameter(Mandatory = $true)] + [string]$parameter, + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [Alias("value")] + [string[]]$arguments + ) + + return Invoke-NssmCommand -arguments (@("set", $service, $parameter) + $arguments) +} + +function Reset-NssmServiceParameter { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service, + [Parameter(Mandatory = $true)] + [Alias("param")] + [string]$parameter + ) + + return Invoke-NssmCommand -arguments @("reset", $service, $parameter) +} + +function Update-NssmServiceParameter { + <# + .SYNOPSIS + A generic cmdlet to idempotently set a nssm service parameter. + .PARAMETER service + [String] The service name + .PARAMETER parameter + [String] The name of the nssm parameter to set. + .PARAMETER arguments + [String[]] Target value (or list of value) or array of arguments to pass to the 'nssm set' command. + .PARAMETER compare + [scriptblock] An optionnal idempotency check scriptblock that must return true when + the current value is equal to the desired value. Usefull when 'nssm get' doesn't return + the same value as 'nssm set' takes in argument, like for the ObjectName parameter. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$service, + + [Parameter(Mandatory = $true)] + [string]$parameter, + + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [AllowEmptyString()] + [AllowNull()] + [Alias("value")] + [string[]]$arguments, + + [Parameter()] + [scriptblock]$compare = { param($actual, $expected) @(Compare-Object -ReferenceObject $actual -DifferenceObject $expected).Length -eq 0 } + ) + + if ($null -eq $arguments) { return } + $arguments = @($arguments | Where-Object { $_ -ne '' }) + + $nssm_result = Get-NssmServiceParameter -service $service -parameter $parameter + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error retrieving $parameter for service ""$service""" + } + + $current_values = @($nssm_result.stdout.split("`n`r") | Where-Object { $_ -ne '' }) + + if (-not $compare.Invoke($current_values, $arguments)) { + if ($PSCmdlet.ShouldProcess($service, "Update '$parameter' parameter")) { + if ($arguments.Count -gt 0) { + $nssm_result = Set-NssmServiceParameter -service $service -parameter $parameter -arguments $arguments + } + else { + $nssm_result = Reset-NssmServiceParameter -service $service -parameter $parameter + } + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error setting $parameter for service ""$service""" + } + } + + $script:diff_text += "-$parameter = $($current_values -join ', ')`n+$parameter = $($arguments -join ', ')`n" + $result.changed_by = $parameter + $result.changed = $true + } +} + +function Test-NssmServiceExist { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + return [bool](Get-Service -Name $service -ErrorAction SilentlyContinue) +} + +function Invoke-NssmStart { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + $nssm_result = Invoke-NssmCommand -arguments @("start", $service) + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error starting service ""$service""" + } +} + +function Invoke-NssmStop { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + $nssm_result = Invoke-NssmCommand -arguments @("stop", $service) + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error stopping service ""$service""" + } +} + +function Start-NssmService { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + $currentStatus = Get-NssmServiceStatus -service $service + + if ($currentStatus.rc -ne 0) { + $result.nssm_error_cmd = $currentStatus.arguments + $result.nssm_error_log = $currentStatus.stderr + Fail-Json -obj $result -message "Error starting service ""$service""" + } + + if ($currentStatus.stdout -notlike "*SERVICE_RUNNING*") { + if ($PSCmdlet.ShouldProcess($service, "Start service")) { + switch -wildcard ($currentStatus.stdout) { + "*SERVICE_STOPPED*" { Invoke-NssmStart -service $service } + + "*SERVICE_CONTINUE_PENDING*" { Invoke-NssmStop -service $service; Invoke-NssmStart -service $service } + "*SERVICE_PAUSE_PENDING*" { Invoke-NssmStop -service $service; Invoke-NssmStart -service $service } + "*SERVICE_PAUSED*" { Invoke-NssmStop -service $service; Invoke-NssmStart -service $service } + "*SERVICE_START_PENDING*" { Invoke-NssmStop -service $service; Invoke-NssmStart -service $service } + "*SERVICE_STOP_PENDING*" { Invoke-NssmStop -service $service; Invoke-NssmStart -service $service } + } + } + + $result.changed_by = "start_service" + $result.changed = $true + } +} + +function Stop-NssmService { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$service + ) + + $currentStatus = Get-NssmServiceStatus -service $service + + if ($currentStatus.rc -ne 0) { + $result.nssm_error_cmd = $currentStatus.arguments + $result.nssm_error_log = $currentStatus.stderr + Fail-Json -obj $result -message "Error stopping service ""$service""" + } + + if ($currentStatus.stdout -notlike "*SERVICE_STOPPED*") { + if ($PSCmdlet.ShouldProcess($service, "Stop service")) { + Invoke-NssmStop -service $service + } + + $result.changed_by = "stop_service" + $result.changed = $true + } +} + +function Add-DepByDate { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [String]$Message, + + [Parameter(Mandatory = $true)] + [String]$Date + ) + + # Legacy doesn't natively support deprecate by date, need to do this manually until we use Ansible.Basic + if (-not $result.ContainsKey('deprecations')) { + $result.deprecations = @() + } + $result.deprecations += @{ + msg = $Message + date = $Date + collection_name = "community.windows" + } +} + +Function ConvertTo-NormalizedUser { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [String]$InputObject + ) + + $systemSid = [System.Security.Principal.SecurityIdentifier]'S-1-5-18' + + # Try to get the SID from the raw value or with LocalSystem (what services consider to be SYSTEM). + try { + $sid = New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList $InputObject + } + catch [ArgumentException] { + if ($InputObject -eq "LocalSystem") { + $sid = $systemSid + } + } + + if (-not $sid) { + $candidates = @(if ($InputObject.Contains('\')) { + $nameSplit = $InputObject.Split('\', 2) + + if ($nameSplit[0] -eq '.') { + # If the domain portion is . try using the hostname then falling back to just the username. + # Usually the hostname just works except when running on a DC where it's a domain account + # where looking up just the username should work. + , @($env:COMPUTERNAME, $nameSplit[1]) + $nameSplit[1] + } + else { + , $nameSplit + } + } + else { + $InputObject + }) + + $sid = for ($i = 0; $i -lt $candidates.Length; $i++) { + $candidate = $candidates[$i] + $ntAccount = New-Object -TypeName System.Security.Principal.NTAccount -ArgumentList $candidate + try { + $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]) + break + } + catch [System.Security.Principal.IdentityNotMappedException] { + if ($i -eq ($candidates.Length - 1)) { + throw + } + continue + } + } + } + + if ($sid -eq $systemSid) { + "LocalSystem" + } + else { + $sid.Translate([System.Security.Principal.NTAccount]).Value + } +} + +if (($null -ne $appParameters) -and ($null -ne $appArguments)) { + Fail-Json $result "'app_parameters' and 'arguments' are mutually exclusive but have both been set." +} + +# Backward compatibility for old parameters style. Remove the block bellow in 2.12 +if ($null -ne $appParameters) { + $dep = @{ + Message = "The parameter 'app_parameters' will be removed soon, use 'arguments' instead" + Date = "2022-07-01" + } + Add-DepByDate @dep + + if ($appParameters -isnot [string]) { + Fail-Json -obj $result -message "The app_parameters parameter must be a string representing a dictionary." + } + + # Convert dict-as-string form to list + $escapedAppParameters = $appParameters.TrimStart("@").TrimStart("{").TrimEnd("}").Replace("; ", "`n").Replace("\", "\\") + $appParametersHash = ConvertFrom-StringData -StringData $escapedAppParameters + + $appParamsArray = @() + $appParametersHash.GetEnumerator() | Foreach-Object { + if ($_.Name -ne "_") { + $appParamsArray += $_.Name + } + $appParamsArray += $_.Value + } + $appArguments = @($appParamsArray) + + # The rest of the code should use only the new $appArguments variable +} + +if ($state -ne 'absent') { + if ($null -eq $application) { + Fail-Json -obj $result -message "The application parameter must be defined when the state is not absent." + } + + if (-not (Test-Path -LiteralPath $application -PathType Leaf)) { + Fail-Json -obj $result -message "The application specified ""$application"" does not exist on the host." + } + + if ($null -eq $appDirectory) { + $appDirectory = (Get-Item -LiteralPath $application).DirectoryName + } + + if ($user) { + $user = ConvertTo-NormalizedUser -InputObject $user + if ( + $user -in @( + (ConvertTo-NormalizedUser -InputObject 'S-1-5-18'), # SYSTEM + (ConvertTo-NormalizedUser -InputObject 'S-1-5-19'), # LOCAL SERVICE + (ConvertTo-NormalizedUser -InputObject 'S-1-5-20') # NETWORK SERVICE + ) + ) { + # These accounts have no password (NSSM expects nothing) + $password = "" + } + elseif ($user.EndsWith('$')) { + # While a gMSA doesn't have a password NSSM will fail with no password so we set a dummy value. The service + # still starts up properly with this so SCManager handles this nicely. + $password = "gsma_password" + } + elseif (-not $password) { + # Any other account requires a password here. + Fail-Json -obj $result -message "User without password is informed for service ""$name""" + } + } +} + + +$service_exists = Test-NssmServiceExist -service $name + +if ($state -eq 'absent') { + if ($service_exists) { + if (-not $check_mode) { + if ((Get-Service -Name $name).Status -ne "Stopped") { + $nssm_result = Invoke-NssmStop -service $name + } + + $nssm_result = Invoke-NssmCommand -arguments @("remove", $name, "confirm") + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error removing service ""$name""" + } + } + + $diff_text += "-[$name]" + $result.changed_by = "remove_service" + $result.changed = $true + } +} +else { + $diff_text_added_prefix = '' + if (-not $service_exists) { + if (-not $check_mode) { + $nssm_result = Invoke-NssmCommand -arguments @("install", $name, $application) + + if ($nssm_result.rc -ne 0) { + $result.nssm_error_cmd = $nssm_result.arguments + $result.nssm_error_log = $nssm_result.stderr + Fail-Json -obj $result -message "Error installing service ""$name""" + } + $service_exists = $true + } + + $diff_text_added_prefix = '+' + $result.changed_by = "install_service" + $result.changed = $true + } + + $diff_text += "$diff_text_added_prefix[$name]`n" + + # We cannot configure a service that was created above in check mode as it won't actually exist + if ($service_exists) { + $common_params = @{ + service = $name + WhatIf = $check_mode + } + + Update-NssmServiceParameter -parameter "Application" -value $application @common_params + Update-NssmServiceParameter -parameter "DisplayName" -value $display_name @common_params + Update-NssmServiceParameter -parameter "Description" -value $description @common_params + + Update-NssmServiceParameter -parameter "AppDirectory" -value $appDirectory @common_params + + + if ($null -ne $appArguments) { + $singleLineParams = "" + if ($appArguments -is [array]) { + $singleLineParams = Argv-ToString -arguments $appArguments + } + else { + $singleLineParams = $appArguments.ToString() + } + + $result.nssm_app_parameters = $appArguments + $result.nssm_single_line_app_parameters = $singleLineParams + + Update-NssmServiceParameter -parameter "AppParameters" -value $singleLineParams @common_params + } + + + Update-NssmServiceParameter -parameter "AppStdout" -value $stdoutFile @common_params + Update-NssmServiceParameter -parameter "AppStderr" -value $stderrFile @common_params + + # set app environment, only do this for now when explicitly requested by caller to + # avoid breaking playbooks which use another / custom scheme for configuring app_env + if ($null -ne $app_env) { + # note: convert app_env dictionary to list of strings in the form key=value and pass that a long as value + $app_env_str = $app_env.GetEnumerator() | ForEach-Object { "$($_.Name)=$($_.Value)" } + + # note: this is important here to make an empty envvar set working properly (in the sense that appenv is reset) + if ($null -eq $app_env_str) { + $app_env_str = '' + } + + Update-NssmServiceParameter -parameter "AppEnvironmentExtra" -value $app_env_str @common_params + } + + ### + # Setup file rotation so we don't accidentally consume too much disk + ### + + #set files to overwrite + Update-NssmServiceParameter -parameter "AppStdoutCreationDisposition" -value 2 @common_params + Update-NssmServiceParameter -parameter "AppStderrCreationDisposition" -value 2 @common_params + + #enable file rotation + Update-NssmServiceParameter -parameter "AppRotateFiles" -value 1 @common_params + + #don't rotate until the service restarts + Update-NssmServiceParameter -parameter "AppRotateOnline" -value $app_rotate_online @common_params + + #both of the below conditions must be met before rotation will happen + #minimum age before rotating + Update-NssmServiceParameter -parameter "AppRotateSeconds" -value 86400 @common_params + + #minimum size before rotating + Update-NssmServiceParameter -parameter "AppRotateBytes" -value $app_rotate_bytes @common_params + + Update-NssmServiceParameter -parameter "DependOnService" -arguments $dependencies @common_params + if ($user) { + # Use custom compare callback to test only the username (and not the password) + Update-NssmServiceParameter -parameter "ObjectName" -arguments @($user, $password) -compare { + param($actual, $expected) + + $actualUser = ConvertTo-NormalizedUser -InputObject $actual[0] + $expectedUser = ConvertTo-NormalizedUser -InputObject $expected[0] + + $actualUser -eq $expectedUser + } @common_params + } + $mappedMode = $start_modes_map.$startMode + Update-NssmServiceParameter -parameter "Start" -value $mappedMode @common_params + if ($state -in "stopped", "restarted") { + Stop-NssmService @common_params + } + + if ($state -in "started", "restarted") { + Start-NssmService @common_params + } + + # Added per users` requests + if ($null -ne $app_stop_method_console) { + Update-NssmServiceParameter -parameter "AppStopMethodConsole" -value $app_stop_method_console @common_params + } + + if ($null -ne $app_stop_method_skip) { + Update-NssmServiceParameter -parameter "AppStopMethodSkip" -value $app_stop_method_skip @common_params + } + } +} + +if ($diff_mode -and $result.changed -eq $true) { + $result.diff = @{ + prepared = $diff_text + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_nssm.py b/ansible_collections/community/windows/plugins/modules/win_nssm.py new file mode 100644 index 00000000..d79f638b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_nssm.py @@ -0,0 +1,216 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Heyo +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_nssm +short_description: Install a service using NSSM +description: + - Install a Windows service using the NSSM wrapper. + - NSSM is a service helper which doesn't suck. See U(https://nssm.cc/) for more information. +requirements: + - "nssm >= 2.24.0 # (install via M(chocolatey.chocolatey.win_chocolatey)) C(win_chocolatey: name=nssm)" +options: + name: + description: + - Name of the service to operate on. + type: str + required: true + state: + description: + - State of the service on the system. + type: str + choices: [ absent, present, started, stopped, restarted ] + default: present + application: + description: + - The application binary to run as a service + - Required when I(state) is C(present), C(started), C(stopped), or C(restarted). + type: path + executable: + description: + - The location of the NSSM utility (in case it is not located in your PATH). + type: path + default: nssm.exe + description: + description: + - The description to set for the service. + type: str + display_name: + description: + - The display name to set for the service. + type: str + working_directory: + description: + - The working directory to run the service executable from (defaults to the directory containing the application binary) + type: path + aliases: [ app_directory, chdir ] + stdout_file: + description: + - Path to receive output. + type: path + stderr_file: + description: + - Path to receive error output. + type: path + app_parameters: + description: + - A string representing a dictionary of parameters to be passed to the application when it starts. + - DEPRECATED since v2.8, please use I(arguments) instead. + - This is mutually exclusive with I(arguments). + type: str + arguments: + description: + - Parameters to be passed to the application when it starts. + - This can be either a simple string or a list. + - This is mutually exclusive with I(app_parameters). + aliases: [ app_parameters_free_form ] + type: str + dependencies: + description: + - Service dependencies that has to be started to trigger startup, separated by comma. + type: list + elements: str + username: + description: + - User to be used for service startup. + - Group managed service accounts must end with C($). + - Before C(1.8.0), this parameter was just C(user). + type: str + aliases: + - user + password: + description: + - Password to be used for service startup. + - This is not required for the well known service accounts and group managed service accounts. + type: str + start_mode: + description: + - If C(auto) is selected, the service will start at bootup. + - C(delayed) causes a delayed but automatic start after boot. + - C(manual) means that the service will start only when another service needs it. + - C(disabled) means that the service will stay off, regardless if it is needed or not. + type: str + choices: [ auto, delayed, disabled, manual ] + default: auto + app_environment: + description: + - Key/Value pairs which will be added to the environment of the service application. + type: dict + version_added: 1.2.0 + app_rotate_bytes: + description: + - NSSM will not rotate any file which is smaller than the configured number of bytes. + type: int + default: 104858 + app_rotate_online: + description: + - If set to 1, nssm can rotate files which grow to the configured file size limit while the service is running. + type: int + choices: + - 0 + - 1 + default: 0 + app_stop_method_console: + description: + - Time to wait after sending Control-C. + type: int + app_stop_method_skip: + description: + - To disable service shutdown methods, set to the sum of one or more of the numbers + - 1 - Don't send Control-C to the console. + - 2 - Don't send WM_CLOSE to windows. + - 4 - Don't send WM_QUIT to threads. + - 8 - Don't call TerminateProcess(). + type: int + choices: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 +seealso: + - module: ansible.windows.win_service +notes: + - The service will NOT be started after its creation when C(state=present). + - Once the service is created, you can use the M(ansible.windows.win_service) module to start it or configure + some additionals properties, such as its startup type, dependencies, service account, and so on. +author: + - Adam Keech (@smadam813) + - George Frank (@georgefrank) + - Hans-Joachim Kliemeck (@h0nIg) + - Michael Wild (@themiwi) + - Kevin Subileau (@ksubileau) + - Shachaf Goldstein (@Shachaf92) +''' + +EXAMPLES = r''' +- name: Install the foo service + community.windows.win_nssm: + name: foo + application: C:\windows\foo.exe + +# This will yield the following command: C:\windows\foo.exe bar "true" +- name: Install the Consul service with a list of parameters + community.windows.win_nssm: + name: Consul + application: C:\consul\consul.exe + arguments: + - agent + - -config-dir=C:\consul\config + +# This is strictly equivalent to the previous example +- name: Install the Consul service with an arbitrary string of parameters + community.windows.win_nssm: + name: Consul + application: C:\consul\consul.exe + arguments: agent -config-dir=C:\consul\config + + +# Install the foo service, and then configure and start it with win_service +- name: Install the foo service, redirecting stdout and stderr to the same file + community.windows.win_nssm: + name: foo + application: C:\windows\foo.exe + stdout_file: C:\windows\foo.log + stderr_file: C:\windows\foo.log + +- name: Configure and start the foo service using win_service + ansible.windows.win_service: + name: foo + dependencies: [ adf, tcpip ] + username: foouser + password: secret + start_mode: manual + state: started + +- name: Install a script based service and define custom environment variables + community.windows.win_nssm: + name: <ServiceName> + application: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: + - <path-to-script> + - <script arg> + app_environment: + AUTH_TOKEN: <token value> + SERVER_URL: https://example.com + PATH: "<path-prepends>;{{ ansible_env.PATH }};<path-appends>" + +- name: Remove the foo service + community.windows.win_nssm: + name: foo + state: absent +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_pagefile.ps1 b/ansible_collections/community/windows/plugins/modules/win_pagefile.ps1 new file mode 100644 index 00000000..627d4e46 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pagefile.ps1 @@ -0,0 +1,243 @@ +#!powershell + +# Copyright: (c) 2017, Liran Nisanov <lirannis@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +######## + +Function Remove-Pagefile { + [CmdletBinding(SupportsShouldProcess)] + param( + $path + ) + Get-CIMInstance Win32_PageFileSetting | Where-Object { $_.Name -eq $path } | ForEach-Object { + if ($PSCmdlet.ShouldProcess($Path, "remove pagefile")) { + $_ | Remove-CIMInstance + } + } +} + +Function Get-Pagefile($path) { + Get-CIMInstance Win32_PageFileSetting | Where-Object { $_.Name -eq $path } +} + +######## + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name '_ansible_check_mode' -type 'bool' -default $false + +$automatic = Get-AnsibleParam -obj $params -name "automatic" -type "bool" +$drive = Get-AnsibleParam -obj $params -name "drive" -type "str" +$fullPath = $drive + ":\pagefile.sys" +$initialSize = Get-AnsibleParam -obj $params -name "initial_size" -type "int" +$maximumSize = Get-AnsibleParam -obj $params -name "maximum_size" -type "int" +$override = Get-AnsibleParam -obj $params -name "override" -type "bool" -default $true +$removeAll = Get-AnsibleParam -obj $params -name "remove_all" -type "bool" -default $false +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "query" -validateset "present", "absent", "query" +$systemManaged = Get-AnsibleParam -obj $params -name "system_managed" -type "bool" -default $false +$test_path = Get-AnsibleParam -obj $params -name "test_path" -type "bool" -default $true + +$result = @{ + changed = $false +} + +if ($removeAll) { + $currentPageFiles = Get-CIMInstance Win32_PageFileSetting + if ($null -ne $currentPageFiles) { + $currentPageFiles | Remove-CIMInstance -WhatIf:$check_mode > $null + $result.changed = $true + } +} + +$regPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" + +if ($null -ne $automatic) { + # change autmoatic managed pagefile + try { + $computerSystem = Get-CIMInstance -Class win32_computersystem + } + catch { + Fail-Json $result "Failed to query WMI computer system object $($_.Exception.Message)" + } + if ($computerSystem.AutomaticManagedPagefile -ne $automatic) { + if (-not $check_mode) { + try { + $computerSystem | Set-CimInstance -Property @{automaticmanagedpagefile = "$automatic" } > $null + } + catch { + Fail-Json $result "Failed to set AutomaticManagedPagefile $($_.Exception.Message)" + } + } + $result.changed = $true + } +} + +if ($state -eq "absent") { + # Remove pagefile + if ($null -ne (Get-Pagefile $fullPath)) { + try { + Remove-Pagefile $fullPath -whatif:$check_mode + } + catch { + Fail-Json $result "Failed to remove pagefile $($_.Exception.Message)" + } + $result.changed = $true + } +} +elseif ($state -eq "present") { + # Remove current pagefile + if ($override) { + if ($null -ne (Get-Pagefile $fullPath)) { + try { + Remove-Pagefile $fullPath -whatif:$check_mode + } + catch { + Fail-Json $result "Failed to remove current pagefile $($_.Exception.Message)" + } + $result.changed = $true + } + } + + # Make sure drive is accessible + if (($test_path) -and (-not (Test-Path -LiteralPath "${drive}:"))) { + Fail-Json $result "Unable to access '${drive}:' drive" + } + + $curPagefile = Get-Pagefile $fullPath + + # Set pagefile + if ($null -eq $curPagefile) { + try { + $pagefile = New-CIMInstance -Class Win32_PageFileSetting -Arguments @{name = $fullPath; } -WhatIf:$check_mode + } + catch { + Fail-Json $result "Failed to create pagefile $($_.Exception.Message)" + } + if (-not ($systemManaged -or $check_mode)) { + try { + $pagefile | Set-CimInstance -Property @{ InitialSize = $initialSize; MaximumSize = $maximumSize } + } + catch { + $originalExceptionMessage = $($_.Exception.Message) + # Try workaround before failing + try { + Remove-Pagefile $fullPath -whatif:$check_mode + } + catch { + Fail-Json $result "Failed to remove pagefile before workaround $($_.Exception.Message) Original exception: $originalExceptionMessage" + } + try { + $pagingFilesValues = (Get-ItemProperty -LiteralPath $regPath).PagingFiles + } + catch { + $msg = -join @( + "Failed to get pagefile settings from the registry for workaround $($_.Exception.Message) " + "Original exception: $originalExceptionMessage" + ) + Fail-Json $result $msg + } + $pagingFilesValues += "$fullPath $initialSize $maximumSize" + try { + Set-ItemProperty -LiteralPath $regPath "PagingFiles" $pagingFilesValues + } + catch { + $msg = -join @( + "Failed to set pagefile settings to the registry for workaround $($_.Exception.Message) " + "Original exception: $originalExceptionMessage" + ) + Fail-Json $result $msg + } + } + } + $result.changed = $true + } + else { + if ((-not $check_mode) -and + -not ($systemManaged) -and + -not ( ($curPagefile.InitialSize -eq 0) -and ($curPagefile.maximumSize -eq 0) ) -and + ( ($curPagefile.InitialSize -ne $initialSize) -or ($curPagefile.maximumSize -ne $maximumSize) ) + ) { + $curPagefile.InitialSize = $initialSize + $curPagefile.MaximumSize = $maximumSize + try { + $curPagefile.Put() | out-null + } + catch { + $originalExceptionMessage = $($_.Exception.Message) + # Try workaround before failing + try { + Remove-Pagefile $fullPath -whatif:$check_mode + } + catch { + Fail-Json $result "Failed to remove pagefile before workaround $($_.Exception.Message) Original exception: $originalExceptionMessage" + } + try { + $pagingFilesValues = (Get-ItemProperty -LiteralPath $regPath).PagingFiles + } + catch { + $msg = -join @( + "Failed to get pagefile settings from the registry for workaround $($_.Exception.Message) " + "Original exception: $originalExceptionMessage" + ) + Fail-Json $result $msg + } + $pagingFilesValues += "$fullPath $initialSize $maximumSize" + try { + + Set-ItemProperty -LiteralPath $regPath -Name "PagingFiles" -Value $pagingFilesValues + } + catch { + $msg = -join @( + "Failed to set pagefile settings to the registry for workaround $($_.Exception.Message) " + "Original exception: $originalExceptionMessage" + ) + Fail-Json $result $msg + } + } + $result.changed = $true + } + } +} +elseif ($state -eq "query") { + $result.pagefiles = @() + + if ($null -eq $drive) { + try { + $pagefiles = Get-CIMInstance Win32_PageFileSetting + } + catch { + Fail-Json $result "Failed to query all pagefiles $($_.Exception.Message)" + } + } + else { + try { + $pagefiles = Get-Pagefile $fullPath + } + catch { + Fail-Json $result "Failed to query specific pagefile $($_.Exception.Message)" + } + } + + # Get all pagefiles + foreach ($currentPagefile in $pagefiles) { + $currentPagefileObject = @{ + name = $currentPagefile.Name + initial_size = $currentPagefile.InitialSize + maximum_size = $currentPagefile.MaximumSize + caption = $currentPagefile.Caption + description = $currentPagefile.Description + } + $result.pagefiles += , $currentPagefileObject + } + + # Get automatic managed pagefile state + try { + $result.automatic_managed_pagefiles = (Get-CIMInstance -Class win32_computersystem).AutomaticManagedPagefile + } + catch { + Fail-Json $result "Failed to query automatic managed pagefile state $($_.Exception.Message)" + } +} +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_pagefile.py b/ansible_collections/community/windows/plugins/modules/win_pagefile.py new file mode 100644 index 00000000..c8b75220 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pagefile.py @@ -0,0 +1,131 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Liran Nisanov <lirannis@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_pagefile +short_description: Query or change pagefile configuration +description: + - Query current pagefile configuration. + - Enable/Disable AutomaticManagedPagefile. + - Create new or override pagefile configuration. +options: + drive: + description: + - The drive of the pagefile. + type: str + initial_size: + description: + - The initial size of the pagefile in megabytes. + type: int + maximum_size: + description: + - The maximum size of the pagefile in megabytes. + type: int + override: + description: + - Override the current pagefile on the drive. + type: bool + default: yes + system_managed: + description: + - Configures current pagefile to be managed by the system. + type: bool + default: no + automatic: + description: + - Configures AutomaticManagedPagefile for the entire system. + type: bool + remove_all: + description: + - Remove all pagefiles in the system, not including automatic managed. + type: bool + default: no + test_path: + description: + - Use Test-Path on the drive to make sure the drive is accessible before creating the pagefile. + type: bool + default: yes + state: + description: + - State of the pagefile. + type: str + choices: [ absent, present, query ] + default: query +notes: +- There is difference between automatic managed pagefiles that configured once for the entire system and system managed pagefile that configured per pagefile. +- InitialSize 0 and MaximumSize 0 means the pagefile is managed by the system. +- Value out of range exception may be caused by several different issues, two common problems - No such drive, Pagefile size is too small. +- Setting a pagefile when AutomaticManagedPagefile is on will disable the AutomaticManagedPagefile. +author: +- Liran Nisanov (@LiranNis) +''' + +EXAMPLES = r''' +- name: Query pagefiles configuration + community.windows.win_pagefile: + +- name: Query C pagefile + community.windows.win_pagefile: + drive: C + +- name: Set C pagefile, don't override if exists + community.windows.win_pagefile: + drive: C + initial_size: 1024 + maximum_size: 1024 + override: no + state: present + +- name: Set C pagefile, override if exists + community.windows.win_pagefile: + drive: C + initial_size: 1024 + maximum_size: 1024 + state: present + +- name: Remove C pagefile + community.windows.win_pagefile: + drive: C + state: absent + +- name: Remove all current pagefiles, enable AutomaticManagedPagefile and query at the end + community.windows.win_pagefile: + remove_all: yes + automatic: yes + +- name: Remove all pagefiles disable AutomaticManagedPagefile and set C pagefile + community.windows.win_pagefile: + drive: C + initial_size: 2048 + maximum_size: 2048 + remove_all: yes + automatic: no + state: present + +- name: Set D pagefile, override if exists + community.windows.win_pagefile: + drive: d + initial_size: 1024 + maximum_size: 1024 + state: present +''' + +RETURN = r''' +automatic_managed_pagefiles: + description: Whether the pagefiles is automatically managed. + returned: When state is query. + type: bool + sample: true +pagefiles: + description: Contains caption, description, initial_size, maximum_size and name for each pagefile in the system. + returned: When state is query. + type: list + sample: + [{"caption": "c:\\ 'pagefile.sys'", "description": "'pagefile.sys' @ c:\\", "initial_size": 2048, "maximum_size": 2048, "name": "c:\\pagefile.sys"}, + {"caption": "d:\\ 'pagefile.sys'", "description": "'pagefile.sys' @ d:\\", "initial_size": 1024, "maximum_size": 1024, "name": "d:\\pagefile.sys"}] + +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_partition.ps1 b/ansible_collections/community/windows/plugins/modules/win_partition.ps1 new file mode 100644 index 00000000..6e451769 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_partition.ps1 @@ -0,0 +1,371 @@ +#!powershell + +# Copyright: (c) 2018, Varun Chopra (@chopraaa) <v@chopraaa.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.2 + +Set-StrictMode -Version 2 + +$ErrorActionPreference = "Stop" + +$spec = @{ + options = @{ + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + drive_letter = @{ type = "str" } + disk_number = @{ type = "int" } + partition_number = @{ type = "int" } + partition_size = @{ type = "str" } + read_only = @{ type = "bool" } + active = @{ type = "bool" } + hidden = @{ type = "bool" } + offline = @{ type = "bool" } + mbr_type = @{ type = "str"; choices = "fat12", "fat16", "extended", "huge", "ifs", "fat32" } + gpt_type = @{ type = "str"; choices = "system_partition", "microsoft_reserved", "basic_data", "microsoft_recovery" } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$state = $module.Params.state +$drive_letter = $module.Params.drive_letter +$disk_number = $module.Params.disk_number +$partition_number = $module.Params.partition_number +$partition_size = $module.Params.partition_size +$read_only = $module.Params.read_only +$active = $module.Params.active +$hidden = $module.Params.hidden +$offline = $module.Params.offline +$mbr_type = $module.Params.mbr_type +$gpt_type = $module.Params.gpt_type + +$size_is_maximum = $false +$ansible_partition = $false +$ansible_partition_size = $null +$partition_style = $null + +$gpt_styles = @{ + system_partition = "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}" + microsoft_reserved = "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" + basic_data = "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" + microsoft_recovery = "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" +} + +$mbr_styles = @{ + fat12 = 1 + fat16 = 4 + extended = 5 + huge = 6 + ifs = 7 + fat32 = 12 +} + +function Convert-SizeToByte { + param( + $Size, + $Units + ) + + switch ($Units) { + "B" { return 1 * $Size } + "KB" { return 1000 * $Size } + "KiB" { return 1024 * $Size } + "MB" { return [Math]::Pow(1000, 2) * $Size } + "MiB" { return [Math]::Pow(1024, 2) * $Size } + "GB" { return [Math]::Pow(1000, 3) * $Size } + "GiB" { return [Math]::Pow(1024, 3) * $Size } + "TB" { return [Math]::Pow(1000, 4) * $Size } + "TiB" { return [Math]::Pow(1024, 4) * $Size } + } +} + +if ($null -ne $partition_size) { + if ($partition_size -eq -1) { + $size_is_maximum = $true + } + elseif ($partition_size -match '^(?<Size>[0-9]+)[ ]*(?<Units>b|kb|kib|mb|mib|gb|gib|tb|tib)$') { + $ansible_partition_size = Convert-SizeToByte -Size $Matches.Size -Units $Matches.Units + } + else { + $module.FailJson("Invalid partition size. B, KB, KiB, MB, MiB, GB, GiB, TB, TiB are valid partition size units") + } +} + +# If partition_exists, we can change or delete it; otherwise we only need the disk to create a new partition +if ($null -ne $disk_number -and $null -ne $partition_number) { + $ansible_partition = Get-Partition -DiskNumber $disk_number -PartitionNumber $partition_number -ErrorAction SilentlyContinue +} +# Check if drive_letter is either auto-assigned or a character from A-Z +elseif ($drive_letter -and $drive_letter -ne "auto" -and -not ($disk_number -and $partition_number)) { + if ($drive_letter -match "^[a-zA-Z]$") { + $ansible_partition = Get-Partition -DriveLetter $drive_letter -ErrorAction SilentlyContinue + } + else { + $module.FailJson("Incorrect usage of drive_letter: specify a drive letter from A-Z or use 'auto' to automatically assign a drive letter") + } +} +elseif ($disk_number) { + try { + Get-Disk -Number $disk_number | Out-Null + } + catch { + $module.FailJson("Specified disk does not exist") + } +} +else { + $module.FailJson("You must provide disk_number, partition_number") +} + +# Partition can't have two partition styles +if ($null -ne $gpt_type -and $null -ne $mbr_type) { + $module.FailJson("Cannot specify both GPT and MBR partition styles. Check which partition style is supported by the disk") +} + +function New-AnsiblePartition { + param( + $DiskNumber, + $Letter, + $SizeMax, + $Size, + $MbrType, + $GptType, + $Style + ) + + $parameters = @{ + DiskNumber = $DiskNumber + } + + if ($null -ne $Letter) { + switch ($Letter) { + "auto" { + $parameters.Add("AssignDriveLetter", $True) + } + default { + $parameters.Add("DriveLetter", $Letter) + } + } + } + + if ($null -ne $Size) { + $parameters.Add("Size", $Size) + } + + if ($null -ne $MbrType) { + $parameters.Add("MbrType", $Style) + } + + if ($null -ne $GptType) { + $parameters.Add("GptType", $Style) + } + + try { + $new_partition = New-Partition @parameters + } + catch { + $module.FailJson("Unable to create a new partition: $($_.Exception.Message)", $_) + } + + return $new_partition +} + + +function Set-AnsiblePartitionState { + param( + $hidden, + $read_only, + $active, + $partition + ) + + $parameters = @{ + DiskNumber = $partition.DiskNumber + PartitionNumber = $partition.PartitionNumber + } + + if ($hidden -NotIn ($null, $partition.IsHidden)) { + $parameters.Add("IsHidden", $hidden) + } + + if ($read_only -NotIn ($null, $partition.IsReadOnly)) { + $parameters.Add("IsReadOnly", $read_only) + } + + if ($active -NotIn ($null, $partition.IsActive)) { + $parameters.Add("IsActive", $active) + } + + try { + Set-Partition @parameters + } + catch { + $module.FailJson("Error changing state of partition: $($_.Exception.Message)", $_) + } +} + + +if ($ansible_partition) { + if ($state -eq "absent") { + try { + $remove_params = @{ + DiskNumber = $ansible_partition.DiskNumber + PartitionNumber = $ansible_partition.PartitionNumber + Confirm = $false + WhatIf = $module.CheckMode + } + Remove-Partition @remove_params + } + catch { + $module.FailJson("There was an error removing the partition: $($_.Exception.Message)", $_) + } + $module.Result.changed = $true + } + else { + + if ($null -ne $gpt_type -and $gpt_styles.$gpt_type -ne $ansible_partition.GptType) { + $module.FailJson("gpt_type is not a valid parameter for existing partitions") + } + if ($null -ne $mbr_type -and $mbr_styles.$mbr_type -ne $ansible_partition.MbrType) { + $module.FailJson("mbr_type is not a valid parameter for existing partitions") + } + + if ($partition_size) { + try { + $get_params = @{ + DiskNumber = $ansible_partition.DiskNumber + PartitionNumber = $ansible_partition.PartitionNumber + } + $max_supported_size = (Get-PartitionSupportedSize @get_params).SizeMax + } + catch { + $module.FailJson("Unable to get maximum supported partition size: $($_.Exception.Message)", $_) + } + if ($size_is_maximum) { + $ansible_partition_size = $max_supported_size + } + if ( + $ansible_partition_size -ne $ansible_partition.Size -and + ($ansible_partition_size - $ansible_partition.Size -gt 1049000 -or $ansible_partition.Size - $ansible_partition_size -gt 1049000) + ) { + if ($ansible_partition.IsReadOnly) { + $module.FailJson("Unable to resize partition: Partition is read only") + } + else { + try { + $resize_params = @{ + DiskNumber = $ansible_partition.DiskNumber + PartitionNumber = $ansible_partition.PartitionNumber + Size = $ansible_partition_size + WhatIf = $module.CheckMode + } + Resize-Partition @resize_params + } + catch { + $module.FailJson("Unable to change partition size: $($_.Exception.Message)", $_) + } + $module.Result.changed = $true + } + } + elseif ($ansible_partition_size -gt $max_supported_size) { + $module.FailJson("Specified partition size exceeds size supported by the partition") + } + } + + if ($drive_letter -NotIn ("auto", $null, $ansible_partition.DriveLetter)) { + if (-not $module.CheckMode) { + try { + Set-Partition -DiskNumber $ansible_partition.DiskNumber -PartitionNumber $ansible_partition.PartitionNumber -NewDriveLetter $drive_letter + } + catch { + $module.FailJson("Unable to change drive letter: $($_.Exception.Message)", $_) + } + } + $module.Result.changed = $true + } + } +} +else { + if ($state -eq "present") { + if ($null -eq $disk_number) { + $module.FailJson("Missing required parameter: disk_number") + } + if ($null -eq $ansible_partition_size -and -not $size_is_maximum) { + $module.FailJson("Missing required parameter: partition_size") + } + if (-not $size_is_maximum) { + try { + $max_supported_size = (Get-Disk -Number $disk_number).LargestFreeExtent + } + catch { + $module.FailJson("Unable to get maximum size supported by disk: $($_.Exception.Message)", $_) + } + + if ($ansible_partition_size -gt $max_supported_size) { + $module.FailJson("Partition size is not supported by disk. Use partition_size: -1 to get maximum size") + } + } + else { + $ansible_partition_size = (Get-Disk -Number $disk_number).LargestFreeExtent + } + + $supp_part_type = (Get-Disk -Number $disk_number).PartitionStyle + if ($null -ne $mbr_type) { + if ($supp_part_type -eq "MBR" -and $mbr_styles.ContainsKey($mbr_type)) { + $partition_style = $mbr_styles.$mbr_type + } + else { + $module.FailJson("Incorrect partition style specified") + } + } + if ($null -ne $gpt_type) { + if ($supp_part_type -eq "GPT" -and $gpt_styles.ContainsKey($gpt_type)) { + $partition_style = $gpt_styles.$gpt_type + } + else { + $module.FailJson("Incorrect partition style specified") + } + } + + if (-not $module.CheckMode) { + $new_params = @{ + DiskNumber = $disk_number + Letter = $drive_letter + Size = $ansible_partition_size + MbrType = $mbr_type + GptType = $gpt_type + Style = $partition_style + } + $ansible_partition = New-AnsiblePartition @new_params + } + $module.Result.changed = $true + } +} + +if ($state -eq "present" -and $ansible_partition) { + if ($offline -NotIn ($null, $ansible_partition.IsOffline)) { + if (-not $module.CheckMode) { + try { + Set-Partition -DiskNumber $ansible_partition.DiskNumber -PartitionNumber $ansible_partition.PartitionNumber -IsOffline $offline + } + catch { + $module.FailJson("Error setting partition offline: $($_.Exception.Message)", $_) + } + } + $module.Result.changed = $true + } + + if ( + $hidden -NotIn ($null, $ansible_partition.IsHidden) -or + $read_only -NotIn ($null, $ansible_partition.IsReadOnly) -or + $active -NotIn ($null, $ansible_partition.IsActive) + ) { + if (-not $module.CheckMode) { + Set-AnsiblePartitionState -hidden $hidden -read_only $read_only -active $active -partition $ansible_partition + } + $module.Result.changed = $true + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_partition.py b/ansible_collections/community/windows/plugins/modules/win_partition.py new file mode 100644 index 00000000..5f8a46f6 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_partition.py @@ -0,0 +1,110 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Varun Chopra (@chopraaa) <v@chopraaa.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_partition +short_description: Creates, changes and removes partitions on Windows Server +description: + - The M(community.windows.win_partition) module can create, modify or delete a partition on a disk +options: + state: + description: + - Used to specify the state of the partition. Use C(absent) to specify if a partition should be removed + and C(present) to specify if the partition should be created or updated. + type: str + choices: [ absent, present] + default: present + drive_letter: + description: + - Used for accessing partitions if I(disk_number) and I(partition_number) are not provided. + - Use C(auto) for automatically assigning a drive letter, or a letter A-Z for manually assigning a drive letter to a new partition. + If not specified, no drive letter is assigned when creating a new partition. + type: str + disk_number: + description: + - Disk number is mandatory for creating new partitions. + - A combination of I(disk_number) and I(partition_number) can be used to specify the partition instead of I(drive_letter) if required. + type: int + partition_number: + description: + - Used in conjunction with I(disk_number) to uniquely identify a partition. + type: int + partition_size: + description: + - Specify size of the partition in B, KB, KiB, MB, MiB, GB, GiB, TB or TiB. Use -1 to specify maximum supported size. + - Partition size is mandatory for creating a new partition but not for updating or deleting a partition. + - The decimal SI prefixes kilo, mega, giga, tera, etc., are powers of 10^3 = 1000. The binary prefixes kibi, mebi, gibi, tebi, etc. + respectively refer to the corresponding power of 2^10 = 1024. + Thus, a gigabyte (GB) is 1000000000 (1000^3) bytes while 1 gibibyte (GiB) is 1073741824 (1024^3) bytes. + type: str + read_only: + description: + - Make the partition read only, restricting changes from being made to the partition. + type: bool + active: + description: + - Specifies if the partition is active and can be used to start the system. This property is only valid when the disk's partition style is MBR. + type: bool + hidden: + description: + - Hides the target partition, making it undetectable by the mount manager. + type: bool + offline: + description: + - Sets the partition offline. + - Adding a mount point (such as a drive letter) will cause the partition to go online again. + type: bool + required: no + mbr_type: + description: + - Specify the partition's MBR type if the disk's partition style is MBR. + - This only applies to new partitions. + - This does not relate to the partitions file system formatting. + type: str + choices: [ fat12, fat16, extended, huge, ifs, fat32 ] + gpt_type: + description: + - Specify the partition's GPT type if the disk's partition style is GPT. + - This only applies to new partitions. + - This does not relate to the partitions file system formatting. + type: str + choices: [ system_partition, microsoft_reserved, basic_data, microsoft_recovery ] + +notes: + - A minimum Operating System Version of 6.2 is required to use this module. To check if your OS is compatible, see + U(https://docs.microsoft.com/en-us/windows/desktop/sysinfo/operating-system-version). + - This module cannot be used for removing the drive letter associated with a partition, initializing a disk or, file system formatting. + - Idempotence works only if you're specifying a drive letter or other unique attributes such as a combination of disk number and partition number. + - For more information, see U(https://msdn.microsoft.com/en-us/library/windows/desktop/hh830524.aspx). +author: + - Varun Chopra (@chopraaa) <v@chopraaa.com> +''' + +EXAMPLES = r''' +- name: Create a partition with drive letter D and size 5 GiB + community.windows.win_partition: + drive_letter: D + partition_size: 5 GiB + disk_number: 1 + +- name: Resize previously created partition to it's maximum size and change it's drive letter to E + community.windows.win_partition: + drive_letter: E + partition_size: -1 + partition_number: 1 + disk_number: 1 + +- name: Delete partition + community.windows.win_partition: + disk_number: 1 + partition_number: 1 + state: absent +''' + +RETURN = r''' +# +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_pester.ps1 b/ansible_collections/community/windows/plugins/modules/win_pester.ps1 new file mode 100644 index 00000000..5ffcf5b7 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pester.ps1 @@ -0,0 +1,125 @@ +#!powershell + +# Copyright: (c) 2017, Erwan Quelin (@equelin) <erwan.quelin@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + output_file = @{ type = "str" } + output_format = @{ type = "str"; default = "NunitXML" } + path = @{ type = "str"; required = $true } + tags = @{ type = "list"; elements = "str" } + test_parameters = @{ type = "dict" } + version = @{ type = "str"; aliases = @(, "minimum_version") } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$output_file = $module.Params.output_file +$output_format = $module.Params.output_format +$path = $module.Params.path +$tags = $module.Params.tags +$test_parameters = $module.Params.test_parameters +$version = $module.Params.version + +Try { + $version = [version]$version +} +Catch { + $module.FailJson("Value '$version' for parameter 'minimum_version' is not a valid version format") +} + +# Make sure path is a real path +Try { + $path = $path.TrimEnd("\") + $path = (Get-item -LiteralPath $path).FullName +} +Catch { + $module.FailJson("Cannot find file or directory: '$path' as it does not exist") +} + +# Import Pester module if available +$Pester = 'Pester' + +If (-not (Get-Module -Name $Pester -ErrorAction SilentlyContinue)) { + If (Get-Module -Name $Pester -ListAvailable -ErrorAction SilentlyContinue) { + Import-Module $Pester + } + else { + $msg = -join @( + "Cannot find module: $Pester. Check if pester is installed, and if it is not, " + "install using win_psmodule or chocolatey.chocolatey.win_chocolatey." + ) + $module.FailJson($msg) + } +} + +# Add actual pester's module version in the ansible's result variable +$Pester_version = (Get-Module -Name $Pester).Version.ToString() +$module.Result.pester_version = $Pester_version + +# Test if the Pester module is available with a version greater or equal than the one specified in the $version parameter +If ((-not (Get-Module -Name $Pester -ErrorAction SilentlyContinue | Where-Object { $_.Version -ge $version })) -and ($version)) { + $module.FailJson("$Pester version is not greater or equal to $version") +} + +#Prepare Invoke-Pester parameters depending of the Pester's version. +#Invoke-Pester output deactivation behave differently depending on the Pester's version +If ($module.Result.pester_version -ge "4.0.0") { + $Parameters = @{ + "show" = "none" + "PassThru" = $True + } +} +else { + $Parameters = @{ + "quiet" = $True + "PassThru" = $True + } +} + +if ($tags.count) { + $Parameters.Tag = $tags +} + +if ($output_file) { + $Parameters.OutputFile = $output_file + $Parameters.OutputFormat = $output_format +} + +# Run Pester tests +If (Test-Path -LiteralPath $path -PathType Leaf) { + $test_parameters_check_mode_msg = '' + if ($test_parameters.keys.count) { + $Parameters.Script = @{Path = $Path ; Parameters = $test_parameters } + $test_parameters_check_mode_msg = " with $($test_parameters.keys -join ',') parameters" + } + else { + $Parameters.Script = $Path + } + + if ($module.CheckMode) { + $module.Result.output = "Run pester test in the file: $path$test_parameters_check_mode_msg" + } + else { + $module.Result.output = Invoke-Pester @Parameters + } +} +else { + $Parameters.Script = $path + + if ($module.CheckMode) { + $module.Result.output = "Run Pester test(s): $path" + } + else { + $module.Result.output = Invoke-Pester @Parameters + } +} + +$module.Result.changed = $true + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_pester.py b/ansible_collections/community/windows/plugins/modules/win_pester.py new file mode 100644 index 00000000..83cc74fa --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pester.py @@ -0,0 +1,104 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_pester +short_description: Run Pester tests on Windows hosts +description: + - Run Pester tests on Windows hosts. + - Test files have to be available on the remote host. +requirements: + - Pester +options: + path: + description: + - Path to a pester test file or a folder where tests can be found. + - If the path is a folder, the module will consider all ps1 files as Pester tests. + type: str + required: true + tags: + description: + - Runs only tests in Describe blocks with specified Tags values. + - Accepts multiple comma separated tags. + type: list + elements: str + output_file: + description: + - Generates an output test report. + type: str + output_format: + description: + - Format of the test report to be generated. + - This parameter is to be used with output_file option. + type: str + default: NunitXML + test_parameters: + description: + - Allows to specify parameters to the test script. + type: dict + version: + description: + - Minimum version of the pester module that has to be available on the remote host. + type: str + aliases: + - minimum_version +author: + - Erwan Quelin (@equelin) + - Prasoon Karunan V (@prasoonkarunan) +''' + +EXAMPLES = r''' +- name: Get facts + ansible.windows.setup: + +- name: Add Pester module + action: + module_name: "{{ 'community.windows.win_psmodule' if ansible_powershell_version >= 5 else 'chocolatey.chocolatey.win_chocolatey' }}" + name: Pester + state: present + +- name: Run the pester test provided in the path parameter. + community.windows.win_pester: + path: C:\Pester + +- name: Run the pester tests only for the tags specified. + community.windows.win_pester: + path: C:\Pester\TestScript.tests + tags: CI,UnitTests + +# Run pesters tests files that are present in the specified folder +# ensure that the pester module version available is greater or equal to the version parameter. +- name: Run the pester test present in a folder and check the Pester module version. + community.windows.win_pester: + path: C:\Pester\test01.test.ps1 + version: 4.1.0 + +- name: Run the pester test present in a folder with given script parameters. + community.windows.win_pester: + path: C:\Pester\test04.test.ps1 + test_parameters: + Process: lsass + Service: bits + +- name: Run the pester test present in a folder and generate NunitXML test result.. + community.windows.win_pester: + path: C:\Pester\test04.test.ps1 + output_file: c:\Pester\resullt\testresult.xml +''' + +RETURN = r''' +pester_version: + description: Version of the pester module found on the remote host. + returned: always + type: str + sample: 4.3.1 +output: + description: Results of the Pester tests. + returned: success + type: list + sample: false +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_power_plan.ps1 b/ansible_collections/community/windows/plugins/modules/win_power_plan.ps1 new file mode 100644 index 00000000..0ebad630 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_power_plan.ps1 @@ -0,0 +1,231 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + name = @{ type = "str"; } + guid = @{ type = "str"; } + } + required_one_of = @( + , @('name', 'guid') + ) + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$name = $module.Params.name +$guid = $module.Params.guid +$module.Result.power_plan_name = $name +$module.Result.power_plan_enabled = $null +$module.Result.all_available_plans = $null + +Add-CSharpType -References @" +using System; +using System.Runtime.InteropServices; + +namespace Ansible.WinPowerPlan +{ + public enum AccessFlags : uint + { + AccessScheme = 16, + AccessSubgroup = 17, + AccessIndividualSetting = 18 + } + + public class NativeMethods + { + [DllImport("Kernel32.dll", SetLastError = true)] + public static extern IntPtr LocalFree( + IntPtr hMen); + + [DllImport("PowrProf.dll")] + public static extern UInt32 PowerEnumerate( + IntPtr RootPowerKey, + IntPtr SchemeGuid, + IntPtr SubGroupOfPowerSettingsGuid, + AccessFlags AccessFlags, + UInt32 Index, + IntPtr Buffer, + ref UInt32 BufferSize); + + [DllImport("PowrProf.dll")] + public static extern UInt32 PowerGetActiveScheme( + IntPtr UserRootPowerKey, + out IntPtr ActivePolicyGuid); + + [DllImport("PowrProf.dll")] + public static extern UInt32 PowerReadFriendlyName( + IntPtr RootPowerKey, + Guid SchemeGuid, + IntPtr SubGroupOfPowerSettingsGuid, + IntPtr PowerSettingGuid, + IntPtr Buffer, + ref UInt32 BufferSize); + + [DllImport("PowrProf.dll")] + public static extern UInt32 PowerSetActiveScheme( + IntPtr UserRootPowerKey, + Guid SchemeGuid); + } +} +"@ + +Function Get-LastWin32ErrorMessage { + param([Int]$ErrorCode) + $exp = New-Object -TypeName System.ComponentModel.Win32Exception -ArgumentList $ErrorCode + $error_msg = "{0} - (Win32 Error Code {1} - 0x{1:X8})" -f $exp.Message, $ErrorCode + return $error_msg +} + +Function Get-PlanName { + param([Guid]$Plan) + + $buffer_size = 0 + $buffer = [IntPtr]::Zero + [Ansible.WinPowerPlan.NativeMethods]::PowerReadFriendlyName([IntPtr]::Zero, $Plan, [IntPtr]::Zero, [IntPtr]::Zero, + $buffer, [ref]$buffer_size) > $null + + $buffer = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($buffer_size) + try { + $res = [Ansible.WinPowerPlan.NativeMethods]::PowerReadFriendlyName([IntPtr]::Zero, $Plan, [IntPtr]::Zero, + [IntPtr]::Zero, $buffer, [ref]$buffer_size) + + if ($res -ne 0) { + $err_msg = Get-LastWin32ErrorMessage -ErrorCode $res + $module.FailJson("Failed to get name for power scheme $Plan - $err_msg") + } + + return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($buffer) + } + finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($buffer) + } +} + +Function Get-PowerPlan { + $plans = @{} + + $i = 0 + while ($true) { + $buffer_size = 0 + $buffer = [IntPtr]::Zero + $res = [Ansible.WinPowerPlan.NativeMethods]::PowerEnumerate([IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, + [Ansible.WinPowerPlan.AccessFlags]::AccessScheme, $i, $buffer, [ref]$buffer_size) + + if ($res -eq 259) { + # 259 == ERROR_NO_MORE_ITEMS, there are no more power plans to enumerate + break + } + elseif ($res -notin @(0, 234)) { + # 0 == ERROR_SUCCESS and 234 == ERROR_MORE_DATA + $err_msg = Get-LastWin32ErrorMessage -ErrorCode $res + $module.FailJson("Failed to get buffer size on local power schemes at index $i - $err_msg") + } + + $buffer = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($buffer_size) + try { + $res = [Ansible.WinPowerPlan.NativeMethods]::PowerEnumerate([IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, + [Ansible.WinPowerPlan.AccessFlags]::AccessScheme, $i, $buffer, [ref]$buffer_size) + + if ($res -eq 259) { + # Server 2008 does not return 259 in the first call above so we do an additional check here + break + } + elseif ($res -notin @(0, 234, 259)) { + $err_msg = Get-LastWin32ErrorMessage -ErrorCode $res + $module.FailJson("Failed to enumerate local power schemes at index $i - $err_msg") + } + $scheme_guid = [System.Runtime.InteropServices.Marshal]::PtrToStructure($buffer, [Type][Guid]) + } + finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($buffer) + } + $scheme_name = Get-PlanName -Plan $scheme_guid + $plans.$scheme_name = $scheme_guid + + $i += 1 + } + + return $plans +} + +Function Get-ActivePowerPlan { + $buffer = [IntPtr]::Zero + $res = [Ansible.WinPowerPlan.NativeMethods]::PowerGetActiveScheme([IntPtr]::Zero, [ref]$buffer) + if ($res -ne 0) { + $err_msg = Get-LastWin32ErrorMessage -ErrorCode $res + $module.FailJson("Failed to get the active power plan - $err_msg") + } + + try { + $active_guid = [System.Runtime.InteropServices.Marshal]::PtrToStructure($buffer, [Type][Guid]) + } + finally { + [Ansible.WinPowerPlan.NativeMethods]::LocalFree($buffer) > $null + } + + return $active_guid +} + +Function Set-ActivePowerPlan { + [CmdletBinding(SupportsShouldProcess = $true)] + param([Guid]$Plan) + + $res = 0 + if ($PSCmdlet.ShouldProcess($Plan, "Set Power Plan")) { + $res = [Ansible.WinPowerPlan.NativeMethods]::PowerSetActiveScheme([IntPtr]::Zero, $Plan) + } + + if ($res -ne 0) { + $err_msg = Get-LastWin32ErrorMessage -ErrorCode $res + $module.FailJson("Failed to set the active power plan to $Plan - $err_msg") + } +} + +# Get all local power plans and the current active plan +$plans = Get-PowerPlan +$active_plan = Get-ActivePowerPlan +$module.Result.all_available_plans = @{} +foreach ($plan_info in $plans.GetEnumerator()) { + $module.Result.all_available_plans.($plan_info.Key) = $plan_info.Value -eq $active_plan +} + +if ($null -ne $name -and $name -notin $plans.Keys) { + $module.FailJson("Defined power_plan: ($name) is not available") +} +if ($null -ne $guid -and $guid -notin $plans.Values) { + $module.FailJson("Defined power_plan: ($guid) is not available") +} +if ($null -ne $name) { + $plan_guid = $plans.$name + $is_active = $active_plan -eq $plans.$name +} +if ($null -ne $guid) { + $plan_guid = $guid + $name = $plans.GetEnumerator() | ForEach-Object { + $name = $_.Key + if ($Plans.Item($name) -eq $plan_guid) { + $name + } + } + $is_active = $active_plan -eq $plans.$name +} + +$module.Result.power_plan_enabled = $is_active + +if (-not $is_active) { + Set-ActivePowerPlan -Plan $plan_guid -WhatIf:$module.CheckMode + $module.Result.changed = $true + $module.Result.power_plan_enabled = $true + foreach ($plan_info in $plans.GetEnumerator()) { + $is_active = $plan_info.Value -eq $plan_guid + $module.Result.all_available_plans.($plan_info.Key) = $is_active + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_power_plan.py b/ansible_collections/community/windows/plugins/modules/win_power_plan.py new file mode 100644 index 00000000..a4d07c7a --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_power_plan.py @@ -0,0 +1,68 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_power_plan +short_description: Changes the power plan of a Windows system +description: + - This module will change the power plan of a Windows system to the defined string. + - Windows defaults to C(balanced) which will cause CPU throttling. In some cases it can be preferable + to change the mode to C(high performance) to increase CPU performance. + - One of I(name) or I(guid) must be provided. +options: + name: + description: + - String value that indicates the desired power plan by name. + - The power plan must already be present on the system. + - Commonly there will be options for C(balanced) and C(high performance). + type: str + required: false + guid: + description: + - String value that indicates the desired power plan by guid. + - The power plan must already be present on the system. + - For out of box guids see U(https://docs.microsoft.com/en-us/windows/win32/power/power-policy-settings). + type: str + required: false + version_added: 1.9.0 + +author: + - Noah Sparks (@nwsparks) +''' + +EXAMPLES = r''' +- name: Change power plan to high performance + community.windows.win_power_plan: + name: high performance + +- name: Change power plan to high performance + community.windows.win_power_plan: + guid: 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c +''' + +RETURN = r''' +power_plan_name: + description: Value of the intended power plan. + returned: always + type: str + sample: balanced +power_plan_enabled: + description: State of the intended power plan. + returned: success + type: bool + sample: true +all_available_plans: + description: The name and enabled state of all power plans. + returned: always + type: dict + sample: | + { + "High performance": false, + "Balanced": true, + "Power saver": false + } +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_product_facts.ps1 b/ansible_collections/community/windows/plugins/modules/win_product_facts.ps1 new file mode 100644 index 00000000..18519af8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_product_facts.ps1 @@ -0,0 +1,105 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +# This modules does not accept any options +$spec = @{ + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +# First try to find the product key from ACPI +try { + $product_key = (Get-CimInstance -Class SoftwareLicensingService).OA3xOriginalProductKey +} +catch { + $product_key = $null +} + +if (-not $product_key) { + # Else try to get it from the registry instead + try { + $data = Get-ItemPropertyValue -LiteralPath "HKLM:\Software\Microsoft\Windows NT\CurrentVersion" -Name DigitalProductId + } + catch { + $data = $null + } + + # And for Windows 2008 R2 + if (-not $data) { + try { + $data = Get-ItemPropertyValue -LiteralPath "HKLM:\Software\Microsoft\Windows NT\CurrentVersion" -Name DigitalProductId4 + } + catch { + $data = $null + } + } + + if ($data) { + $product_key = $null + $isWin8 = [int]($data[66] / 6) -band 1 + $HF7 = 0xF7 + $data[66] = ($data[66] -band $HF7) -bOr (($isWin8 -band 2) * 4) + $hexdata = $data[52..66] + $chardata = "B", "C", "D", "F", "G", "H", "J", "K", "M", "P", "Q", "R", "T", "V", "W", "X", "Y", "2", "3", "4", "6", "7", "8", "9" + + # Decode base24 binary data + for ($i = 24; $i -ge 0; $i--) { + $k = 0 + for ($j = 14; $j -ge 0; $j--) { + $k = $k * 256 -bxor $hexdata[$j] + $hexdata[$j] = [math]::truncate($k / 24) + $k = $k % 24 + } + $product_key_output = $chardata[$k] + $product_key_output + $last = $k + } + + $product_key_tmp1 = $product_key_output.SubString(1, $last) + $product_key_tmp2 = $product_key_output.SubString(1, $product_key_output.Length - 1) + if ($last -eq 0) { + $product_key_output = "N" + $product_key_tmp2 + } + else { + $product_key_output = $product_key_tmp2.Insert($product_key_tmp2.IndexOf($product_key_tmp1) + $product_key_tmp1.Length, "N") + } + $num = 0 + $product_key_split = @() + for ($i = 0; $i -le 4; $i++) { + $product_key_split += $product_key_output.SubString($num, 5) + $num += 5 + } + $product_key = $product_key_split -join "-" + } +} + +# Retrieve license information +$license_info = Get-CimInstance SoftwareLicensingProduct | Where-Object PartialProductKey + +$winlicense_status = switch ($license_info.LicenseStatus) { + 0 { "Unlicensed" } + 1 { "Licensed" } + 2 { "OOBGrace" } + 3 { "OOTGrace" } + 4 { "NonGenuineGrace" } + 5 { "Notification" } + 6 { "ExtendedGrace" } + default { $null } +} + +$winlicense_edition = $license_info.Name +$winlicense_channel = $license_info.ProductKeyChannel + +$module.Result.ansible_facts = @{ + ansible_os_product_id = (Get-CimInstance Win32_OperatingSystem).SerialNumber + ansible_os_product_key = $product_key + ansible_os_license_edition = $winlicense_edition + ansible_os_license_channel = $winlicense_channel + ansible_os_license_status = $winlicense_status +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_product_facts.py b/ansible_collections/community/windows/plugins/modules/win_product_facts.py new file mode 100644 index 00000000..5353ce55 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_product_facts.py @@ -0,0 +1,61 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_product_facts +short_description: Provides Windows product and license information +description: +- Provides Windows product and license information. +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Get product id and product key + community.windows.win_product_facts: + +- name: Display Windows edition + debug: + var: ansible_os_license_edition + +- name: Display Windows license status + debug: + var: ansible_os_license_status +''' + +RETURN = r''' +ansible_facts: + description: Dictionary containing all the detailed information about the Windows product and license. + returned: always + type: complex + contains: + ansible_os_license_channel: + description: The Windows license channel. + returned: always + type: str + sample: Volume:MAK + ansible_os_license_edition: + description: The Windows license edition. + returned: always + type: str + sample: Windows(R) ServerStandard edition + ansible_os_license_status: + description: The Windows license status. + returned: always + type: str + sample: Licensed + ansible_os_product_id: + description: The Windows product ID. + returned: always + type: str + sample: 00326-10000-00000-AA698 + ansible_os_product_key: + description: The Windows product key. + returned: always + type: str + sample: T49TD-6VFBW-VV7HY-B2PXY-MY47H +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psexec.ps1 b/ansible_collections/community/windows/plugins/modules/win_psexec.ps1 new file mode 100644 index 00000000..76c5ff8a --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psexec.ps1 @@ -0,0 +1,161 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + +# See also: https://technet.microsoft.com/en-us/sysinternals/pxexec.aspx + +$spec = @{ + options = @{ + command = @{ type = 'str'; required = $true } + executable = @{ type = 'path'; default = 'psexec.exe' } + hostnames = @{ type = 'list'; elements = 'str' } + username = @{ type = 'str' } + password = @{ type = 'str'; no_log = $true } + chdir = @{ type = 'path' } + wait = @{ type = 'bool'; default = $true } + nobanner = @{ type = 'bool'; default = $false } + noprofile = @{ type = 'bool'; default = $false } + elevated = @{ type = 'bool'; default = $false } + limited = @{ type = 'bool'; default = $false } + system = @{ type = 'bool'; default = $false } + interactive = @{ type = 'bool'; default = $false } + session = @{ type = 'int' } + priority = @{ type = 'str'; choices = @( 'background', 'low', 'belownormal', 'abovenormal', 'high', 'realtime' ) } + timeout = @{ type = 'int' } + } +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$command = $module.Params.command +$executable = $module.Params.executable +$hostnames = $module.Params.hostnames +$username = $module.Params.username +$password = $module.Params.password +$chdir = $module.Params.chdir +$wait = $module.Params.wait +$nobanner = $module.Params.nobanner +$noprofile = $module.Params.noprofile +$elevated = $module.Params.elevated +$limited = $module.Params.limited +$system = $module.Params.system +$interactive = $module.Params.interactive +$session = $module.Params.session +$priority = $module.Params.Priority +$timeout = $module.Params.timeout + +$module.Result.changed = $true + +If (-Not (Get-Command $executable -ErrorAction SilentlyContinue)) { + $module.FailJson("Executable '$executable' was not found.") +} + +$arguments = [System.Collections.Generic.List`1[String]]@($executable) + +If ($nobanner -eq $true) { + $arguments.Add("-nobanner") +} + +# Support running on local system if no hostname is specified +If ($hostnames) { + $hostname_argument = ($hostnames | Sort-Object -Unique) -join ',' + $arguments.Add("\\$hostname_argument") +} + +# Username is optional +If ($null -ne $username) { + $arguments.Add("-u") + $arguments.Add($username) +} + +# Password is optional +If ($null -ne $password) { + $arguments.Add("-p") + $arguments.Add($password) +} + +If ($null -ne $chdir) { + $arguments.Add("-w") + $arguments.Add($chdir) +} + +If ($wait -eq $false) { + $arguments.Add("-d") +} + +If ($noprofile -eq $true) { + $arguments.Add("-e") +} + +If ($elevated -eq $true) { + $arguments.Add("-h") +} + +If ($system -eq $true) { + $arguments.Add("-s") +} + +If ($interactive -eq $true) { + $arguments.Add("-i") + If ($null -ne $session) { + $arguments.Add($session) + } +} + +If ($limited -eq $true) { + $arguments.Add("-l") +} + +If ($null -ne $priority) { + $arguments.Add("-$priority") +} + +If ($null -ne $timeout) { + $arguments.Add("-n") + $arguments.Add($timeout) +} + +$arguments.Add("-accepteula") + +$argument_string = Argv-ToString -arguments $arguments + +# Add the command at the end of the argument string, we don't want to escape +# that as psexec doesn't expect it to be one arg +$argument_string += " $command" + +$start_datetime = [DateTime]::UtcNow + +# Replace password with *PASSWORD_REPLACED* to avoid disclosing sensitive data +$toLog = $argument_string +if ($password) { + $maskedPassword = Argv-ToString $password + $toLog = $toLog.Replace($maskedPassword, "*PASSWORD_REPLACED*") +} + +$module.Result.psexec_command = $toLog + +$command_result = Run-Command -command $argument_string + +$end_datetime = [DateTime]::UtcNow + +$module.Result.stdout = $command_result.stdout +$module.Result.stderr = $command_result.stderr + +If ($wait -eq $true) { + $module.Result.rc = $command_result.rc +} +else { + $module.Result.rc = 0 + $module.Result.pid = $command_result.rc +} + +$module.Result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psexec.py b/ansible_collections/community/windows/plugins/modules/win_psexec.py new file mode 100644 index 00000000..44d37654 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psexec.py @@ -0,0 +1,167 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psexec +short_description: Runs commands (remotely) as another (privileged) user +description: +- Run commands (remotely) through the PsExec service. +- Run commands as another (domain) user (with elevated privileges). +requirements: +- Microsoft PsExec +options: + command: + description: + - The command line to run through PsExec (limited to 260 characters). + type: str + required: yes + executable: + description: + - The location of the PsExec utility (in case it is not located in your PATH). + type: path + default: psexec.exe + hostnames: + description: + - The hostnames to run the command. + - If not provided, the command is run locally. + type: list + elements: str + username: + description: + - The (remote) user to run the command as. + - If not provided, the current user is used. + type: str + password: + description: + - The password for the (remote) user to run the command as. + - This is mandatory in order authenticate yourself. + type: str + chdir: + description: + - Run the command from this (remote) directory. + type: path + nobanner: + description: + - Do not display the startup banner and copyright message. + - This only works for specific versions of the PsExec binary. + type: bool + default: no + noprofile: + description: + - Run the command without loading the account's profile. + type: bool + default: no + elevated: + description: + - Run the command with elevated privileges. + type: bool + default: no + interactive: + description: + - Run the program so that it interacts with the desktop on the remote system. + type: bool + default: no + session: + description: + - Specifies the session ID to use. + - This parameter works in conjunction with I(interactive). + - It has no effect when I(interactive) is set to C(no). + type: int + limited: + description: + - Run the command as limited user (strips the Administrators group and allows only privileges assigned to the Users group). + type: bool + default: no + system: + description: + - Run the remote command in the System account. + type: bool + default: no + priority: + description: + - Used to run the command at a different priority. + choices: [ abovenormal, background, belownormal, high, low, realtime ] + type: str + timeout: + description: + - The connection timeout in seconds + type: int + wait: + description: + - Wait for the application to terminate. + - Only use for non-interactive applications. + type: bool + default: yes +notes: +- More information related to Microsoft PsExec is available from + U(https://technet.microsoft.com/en-us/sysinternals/bb897553.aspx) +seealso: +- module: community.windows.psexec +- module: ansible.builtin.raw +- module: ansible.windows.win_command +- module: ansible.windows.win_shell +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Test the PsExec connection to the local system (target node) with your user + community.windows.win_psexec: + command: whoami.exe + +- name: Run regedit.exe locally (on target node) as SYSTEM and interactively + community.windows.win_psexec: + command: regedit.exe + interactive: yes + system: yes + +- name: Run the setup.exe installer on multiple servers using the Domain Administrator + community.windows.win_psexec: + command: E:\setup.exe /i /IACCEPTEULA + hostnames: + - remote_server1 + - remote_server2 + username: DOMAIN\Administrator + password: some_password + priority: high + +- name: Run PsExec from custom location C:\Program Files\sysinternals\ + community.windows.win_psexec: + command: netsh advfirewall set allprofiles state off + executable: C:\Program Files\sysinternals\psexec.exe + hostnames: [ remote_server ] + password: some_password + priority: low +''' + +RETURN = r''' +cmd: + description: The complete command line used by the module, including PsExec call and additional options. + returned: always + type: str + sample: psexec.exe -nobanner \\remote_server -u "DOMAIN\Administrator" -p "some_password" -accepteula E:\setup.exe +pid: + description: The PID of the async process created by PsExec. + returned: when C(wait=False) + type: int + sample: 1532 +rc: + description: The return code for the command. + returned: always + type: int + sample: 0 +stdout: + description: The standard output from the command. + returned: always + type: str + sample: Success. +stderr: + description: The error output from the command. + returned: always + type: str + sample: Error 15 running E:\setup.exe +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psmodule.ps1 b/ansible_collections/community/windows/plugins/modules/win_psmodule.ps1 new file mode 100644 index 00000000..47515952 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psmodule.ps1 @@ -0,0 +1,549 @@ +#!powershell + +# Copyright: (c) 2018, Wojciech Sciesinski <wojciech[at]sciesinski[dot]net> +# Copyright: (c) 2017, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +# win_psmodule (Windows PowerShell modules Additions/Removals/Updates) + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$required_version = Get-AnsibleParam -obj $params -name "required_version" -type "str" +$minimum_version = Get-AnsibleParam -obj $params -name "minimum_version" -type "str" +$maximum_version = Get-AnsibleParam -obj $params -name "maximum_version" -type "str" +$repo = Get-AnsibleParam -obj $params -name "repository" -type "str" +$repo_user = Get-AnsibleParam -obj $params -name "username" -type "str" +$repo_pass = Get-AnsibleParam -obj $params -name "password" -type "str" +$url = Get-AnsibleParam -obj $params -name "url" -type str +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent", "latest" +$allow_clobber = Get-AnsibleParam -obj $params -name "allow_clobber" -type "bool" -default $false +$skip_publisher_check = Get-AnsibleParam -obj $params -name "skip_publisher_check" -type "bool" -default $false +$allow_prerelease = Get-AnsibleParam -obj $params -name "allow_prerelease" -type "bool" -default $false +$accept_license = Get-AnsibleParam -obj $params -name "accept_license" -type "bool" -default $false + +$result = @{changed = $false + output = "" + nuget_changed = $false + repository_changed = $false +} + + +# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5) +$security_protocols = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::SystemDefault +if ([System.Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls11 +} +if ([System.Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls12 +} +[System.Net.ServicePointManager]::SecurityProtocol = $security_protocols + + +Function Install-NugetProvider { + Param( + [Bool]$CheckMode + ) + $PackageProvider = Get-PackageProvider -ListAvailable | Where-Object { ($_.name -eq 'Nuget') -and ($_.version -ge "2.8.5.201") } + if (-not($PackageProvider)) { + try { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -WhatIf:$CheckMode | out-null + $result.changed = $true + $result.nuget_changed = $true + } + catch [ System.Exception ] { + $ErrorMessage = "Problems adding package provider: $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } +} + +Function Install-PrereqModule { + Param( + [Switch]$TestInstallationOnly, + [bool]$AllowClobber, + [Bool]$CheckMode, + [bool]$AcceptLicense, + [string]$Repository + ) + + # Those are minimum required versions of modules. + $PrereqModules = @{ + PackageManagement = '1.1.7' + PowerShellGet = '1.6.0' + } + + [Bool]$PrereqModulesInstalled = $true + + ForEach ( $Name in $PrereqModules.Keys ) { + + $ExistingPrereqModule = Get-Module -ListAvailable | Where-Object { ($_.name -eq $Name) -and ($_.version -ge $PrereqModules[$Name]) } + + if ( -not $ExistingPrereqModule ) { + if ( $TestInstallationOnly ) { + $PrereqModulesInstalled = $false + } + else { + try { + $install_params = @{ + Name = $Name + MinimumVersion = $PrereqModules[$Name] + Force = $true + WhatIf = $CheckMode + } + $installCmd = Get-Command -Name Install-Module + if ($installCmd.Parameters.ContainsKey('SkipPublisherCheck')) { + $install_params.SkipPublisherCheck = $true + } + if ($installCmd.Parameters.ContainsKey('AllowClobber')) { + $install_params.AllowClobber = $AllowClobber + } + if ($installCmd.Parameters.ContainsKey('AcceptLicense')) { + $install_params.AcceptLicense = $AcceptLicense + } + if ($Repository) { + $install_params.Repository = $Repository + } + + Install-Module @install_params > $null + + if ( $Name -eq 'PowerShellGet' ) { + # An order has to be reverted due to dependency + Remove-Module -Name PowerShellGet, PackageManagement -Force + Import-Module -Name PowerShellGet, PackageManagement -Force + } + + $result.changed = $true + } + catch [ System.Exception ] { + $ErrorMessage = "Problems adding a prerequisite module $Name $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } + } + } + + if ( $TestInstallationOnly ) { + $PrereqModulesInstalled + } +} + +Function Get-PsModule { + Param( + [Parameter(Mandatory = $true)] + [String]$Name, + [String]$RequiredVersion, + [String]$MinimumVersion, + [String]$MaximumVersion + ) + + $ExistingModule = @{ + Exists = $false + Version = "" + } + + $ExistingModules = Get-Module -Listavailable | Where-Object { ($_.name -eq $Name) } + $ExistingModulesCount = $($ExistingModules | Measure-Object).Count + + if ( $ExistingModulesCount -gt 0 ) { + + $ExistingModules | Add-Member -MemberType ScriptProperty -Name FullVersion -Value { + if ( $null -ne ( $this.PrivateData ) ) { + [String]"$($this.Version)-$(($this | Select-Object -ExpandProperty PrivateData).PSData.Prerelease)".TrimEnd('-') + } + else { + [String]"$($this.Version)" + } + } + + if ( -not ($RequiredVersion -or + $MinimumVersion -or + $MaximumVersion) ) { + + $ReturnedModule = $ExistingModules | Select-Object -First 1 + } + elseif ( $RequiredVersion ) { + $ReturnedModule = $ExistingModules | Where-Object -FilterScript { $_.FullVersion -eq $RequiredVersion } + } + elseif ( $MinimumVersion -and $MaximumVersion ) { + $ReturnedModule = $ExistingModules | + Where-Object -FilterScript { $MinimumVersion -le $_.Version -and $MaximumVersion -ge $_.Version } | + Select-Object -First 1 + } + elseif ( $MinimumVersion ) { + $ReturnedModule = $ExistingModules | Where-Object -FilterScript { $MinimumVersion -le $_.Version } | Select-Object -First 1 + } + elseif ( $MaximumVersion ) { + $ReturnedModule = $ExistingModules | Where-Object -FilterScript { $MaximumVersion -ge $_.Version } | Select-Object -First 1 + } + } + + $ReturnedModuleCount = ($ReturnedModule | Measure-Object).Count + + if ( $ReturnedModuleCount -eq 1 ) { + $ExistingModule.Exists = $true + $ExistingModule.Version = $ReturnedModule.FullVersion + } + + $ExistingModule +} + +Function Add-DefinedParameter { + Param ( + [Parameter(Mandatory = $true)] + [Hashtable]$Hashtable, + [Parameter(Mandatory = $true)] + [String[]]$ParametersNames + ) + + ForEach ($ParameterName in $ParametersNames) { + $ParameterVariable = Get-Variable -Name $ParameterName -ErrorAction SilentlyContinue + if ( $ParameterVariable.Value -and $Hashtable.Keys -notcontains $ParameterName ) { + $Hashtable.Add($ParameterName, $ParameterVariable.Value) + } + } + + $Hashtable +} + +Function Install-PsModule { + Param( + [Parameter(Mandatory = $true)] + [String]$Name, + [String]$RequiredVersion, + [String]$MinimumVersion, + [String]$MaximumVersion, + [String]$Repository, + [System.Management.Automation.PSCredential]$Credential = [System.Management.Automation.PSCredential]::Empty, + [Bool]$AllowClobber, + [Bool]$SkipPublisherCheck, + [Bool]$AllowPrerelease, + [Bool]$CheckMode, + [Bool]$AcceptLicense + ) + + $getParams = @{ + Name = $Name + RequiredVersion = $RequiredVersion + MinimumVersion = $MinimumVersion + MaximumVersion = $MaximumVersion + } + $ExistingModuleBefore = Get-PsModule @getParams + + if ( -not $ExistingModuleBefore.Exists ) { + try { + # Install NuGet provider if needed. + Install-NugetProvider -CheckMode $CheckMode + + $ht = @{ + Name = $Name + WhatIf = $CheckMode + Force = $true + AcceptLicense = $AcceptLicense + } + + [String[]]$ParametersNames = @("RequiredVersion", "MinimumVersion", "MaximumVersion", "AllowPrerelease", + "AllowClobber", "SkipPublisherCheck", "Repository", "Credential") + + $ht = Add-DefinedParameter -Hashtable $ht -ParametersNames $ParametersNames + + Install-Module @ht -ErrorVariable ErrorDetails | out-null + + $result.changed = $true + $result.output = "Module $($Name) installed" + } + catch [ System.Exception ] { + $ErrorMessage = "Problems installing $($Name) module: $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } + else { + $result.output = "Module $($Name) already present" + } +} + +Function Remove-PsModule { + Param( + [Parameter(Mandatory = $true)] + [String]$Name, + [String]$RequiredVersion, + [String]$MinimumVersion, + [String]$MaximumVersion, + [Bool]$CheckMode + ) + # If module is present, uninstalls it. + if (Get-Module -Listavailable | Where-Object { $_.name -eq $Name }) { + try { + $ht = @{ + Name = $Name + Confirm = $false + Force = $true + } + + $ExistingModuleBefore = Get-PsModule -Name $Name -RequiredVersion $RequiredVersion -MinimumVersion $MinimumVersion -MaximumVersion $MaximumVersion + + [String[]]$ParametersNames = @("RequiredVersion", "MinimumVersion", "MaximumVersion") + + $ht = Add-DefinedParameter -Hashtable $ht -ParametersNames $ParametersNames + + if ( -not ( $RequiredVersion -or $MinimumVersion -or $MaximumVersion ) ) { + $ht.Add("AllVersions", $true) + } + + if ( $ExistingModuleBefore.Exists) { + # The Force parameter overwrite the WhatIf parameter + if ( -not $CheckMode ) { + Uninstall-Module @ht -ErrorVariable ErrorDetails | out-null + } + $result.changed = $true + $result.output = "Module $($Name) removed" + } + } + catch [ System.Exception ] { + $ErrorMessage = "Problems uninstalling $($Name) module: $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } + else { + $result.output = "Module $($Name) removed" + } +} + +Function Find-LatestPsModule { + Param( + [Parameter(Mandatory = $true)] + [String]$Name, + [String]$Repository, + [System.Management.Automation.PSCredential]$Credential = [System.Management.Automation.PSCredential]::Empty, + [Bool]$AllowPrerelease, + [Bool]$CheckMode + ) + + try { + $ht = @{ + Name = $Name + } + + [String[]]$ParametersNames = @("AllowPrerelease", "Repository", "Credential") + + $ht = Add-DefinedParameter -Hashtable $ht -ParametersNames $ParametersNames + + $LatestModule = Find-Module @ht + $LatestModuleVersion = $LatestModule.Version + } + catch [ System.Exception ] { + $ErrorMessage = "Cant find the module $($Name): $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + + $LatestModuleVersion +} + +Function Install-Repository { + Param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Url, + [bool]$CheckMode + ) + # Legacy doesn't natively support deprecate by date, need to do this manually until we use Ansible.Basic + if (-not $result.ContainsKey('deprecations')) { + $result.deprecations = @() + } + $msg = -join @( + "Adding a repo with this module is deprecated, the repository parameter should only be used to select a repo. " + "Use community.windows.win_psrepository to manage repos" + ) + $result.deprecations += @{ + msg = $msg + date = "2021-07-01" + collection_name = "community.windows" + } + # Install NuGet provider if needed. + Install-NugetProvider -CheckMode $CheckMode + + $Repos = (Get-PSRepository).SourceLocation + + # If repository isn't already present, try to register it as trusted. + if ($Repos -notcontains $Url) { + try { + if ( -not ($CheckMode) ) { + Register-PSRepository -Name $Name -SourceLocation $Url -InstallationPolicy Trusted -ErrorAction Stop + } + $result.changed = $true + $result.repository_changed = $true + } + catch { + $ErrorMessage = "Problems registering $($Name) repository: $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } +} + +Function Remove-Repository { + Param( + [Parameter(Mandatory = $true)] + [string]$Name, + [bool]$CheckMode + ) + # Legacy doesn't natively support deprecate by date, need to do this manually until we use Ansible.Basic + if (-not $result.ContainsKey('deprecations')) { + $result.deprecations = @() + } + $result.deprecations += @{ + msg = "Removing a repo with this module is deprecated, use community.windows.win_psrepository to manage repos" + date = "2021-07-01" + collection_name = "community.windows" + } + + $Repo = (Get-PSRepository).Name + + # Try to remove the repository + if ($Repo -contains $Name) { + try { + if ( -not ($CheckMode) ) { + Unregister-PSRepository -Name $Name -ErrorAction Stop + } + $result.changed = $true + $result.repository_changed = $true + } + catch [ System.Exception ] { + $ErrorMessage = "Problems unregistering $($Name)repository: $($_.Exception.Message)" + Fail-Json $result $ErrorMessage + } + } +} + +# Check PowerShell version, fail if < 5.0 and required modules are not installed +$PsVersion = $PSVersionTable.PSVersion +if ($PsVersion.Major -lt 5 ) { + $PrereqModulesInstalled = Install-PrereqModule -TestInstallationOnly + if ( -not $PrereqModulesInstalled ) { + $ErrorMessage = -join @( + "Modules PowerShellGet and PackageManagement in versions 1.6.0 and 1.1.7 respectively " + "have to be installed before using the win_psmodule." + ) + Fail-Json $result $ErrorMessage + } +} + +if ( $required_version -and ( $minimum_version -or $maximum_version ) ) { + $ErrorMessage = "Parameters required_version and minimum/maximum_version are mutually exclusive." + Fail-Json $result $ErrorMessage +} + +if ( $allow_prerelease -and ( $minimum_version -or $maximum_version ) ) { + $ErrorMessage = "Parameters minimum_version, maximum_version can't be used with the parameter allow_prerelease." + Fail-Json $result $ErrorMessage +} + +if ( $allow_prerelease -and $state -eq "absent" ) { + $ErrorMessage = "The parameter allow_prerelease can't be used with state set to 'absent'." + Fail-Json $result $ErrorMessage +} + +if ( ($state -eq "latest") -and + ( $required_version -or $minimum_version -or $maximum_version ) ) { + $ErrorMessage = "When the parameter state is equal 'latest' you can use any of required_version, minimum_version, maximum_version." + Fail-Json $result $ErrorMessage +} + +if ( $repo -and (-not $url) ) { + $RepositoryExists = Get-PSRepository -Name $repo -ErrorAction SilentlyContinue + if ( $null -eq $RepositoryExists) { + $ErrorMessage = "The repository $repo doesn't exist." + Fail-Json $result $ErrorMessage + } + +} + +if ($repo_user -and $repo_pass ) { + $repo_credential = New-Object -TypeName PSCredential ($repo_user, ($repo_pass | ConvertTo-SecureString -AsPlainText -Force)) +} + +if ( ($allow_clobber -or $allow_prerelease -or $skip_publisher_check -or + $required_version -or $minimum_version -or $maximum_version -or $accept_license) ) { + # Update the PowerShellGet and PackageManagement modules. + # It's required to support AllowClobber, AllowPrerelease parameters. + Install-PrereqModule -AllowClobber $allow_clobber -CheckMode $check_mode -AcceptLicense $accept_license -Repository $repo +} + +Import-Module -Name PackageManagement, PowerShellGet -Force + +if ($state -eq "present") { + if (($repo) -and ($url)) { + Install-Repository -Name $repo -Url $url -CheckMode $check_mode + } + else { + $ErrorMessage = "Repository Name and Url are mandatory if you want to add a new repository" + } + + if ($name) { + $ht = @{ + Name = $name + RequiredVersion = $required_version + MinimumVersion = $minimum_version + MaximumVersion = $maximum_version + Repository = $repo + AllowClobber = $allow_clobber + SkipPublisherCheck = $skip_publisher_check + AllowPrerelease = $allow_prerelease + CheckMode = $check_mode + Credential = $repo_credential + AcceptLicense = $accept_license + } + Install-PsModule @ht + } +} +elseif ($state -eq "absent") { + if ($repo) { + Remove-Repository -Name $repo -CheckMode $check_mode + } + + if ($name) { + $ht = @{ + Name = $Name + CheckMode = $check_mode + RequiredVersion = $required_version + MinimumVersion = $minimum_version + MaximumVersion = $maximum_version + } + Remove-PsModule @ht + } +} +elseif ( $state -eq "latest") { + + $ht = @{ + Name = $Name + AllowPrerelease = $allow_prerelease + Repository = $repo + CheckMode = $check_mode + Credential = $repo_credential + } + + $LatestVersion = Find-LatestPsModule @ht + + $ExistingModule = Get-PsModule $Name + + if ( $LatestVersion.Version -ne $ExistingModule.Version ) { + + $ht = @{ + Name = $Name + RequiredVersion = $LatestVersion + Repository = $repo + AllowClobber = $allow_clobber + SkipPublisherCheck = $skip_publisher_check + AllowPrerelease = $allow_prerelease + CheckMode = $check_mode + Credential = $repo_credential + AcceptLicense = $accept_license + } + Install-PsModule @ht + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_psmodule.py b/ansible_collections/community/windows/plugins/modules/win_psmodule.py new file mode 100644 index 00000000..9808199e --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psmodule.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Wojciech Sciesinski <wojciech[at]sciesinski[dot]net> +# Copyright: (c) 2017, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psmodule +short_description: Adds or removes a Windows PowerShell module +description: + - This module helps to install Windows PowerShell modules and register custom modules repository on Windows-based systems. +options: + name: + description: + - Name of the Windows PowerShell module that has to be installed. + type: str + required: yes + state: + description: + - If C(present) a new module is installed. + - If C(absent) a module is removed. + - If C(latest) a module is updated to the newest version. + type: str + choices: [ absent, latest, present ] + default: present + required_version: + description: + - The exact version of the PowerShell module that has to be installed. + type: str + minimum_version: + description: + - The minimum version of the PowerShell module that has to be installed. + type: str + maximum_version: + description: + - The maximum version of the PowerShell module that has to be installed. + type: str + allow_clobber: + description: + - If C(yes) allows install modules that contains commands those have the same names as commands that already exists. + type: bool + default: no + skip_publisher_check: + description: + - If C(yes), allows you to install a different version of a module that already exists on your computer in the case when a different one + is not digitally signed by a trusted publisher and the newest existing module is digitally signed by a trusted publisher. + type: bool + default: no + allow_prerelease: + description: + - If C(yes) installs modules marked as prereleases. + - It doesn't work with the parameters C(minimum_version) and/or C(maximum_version). + - It doesn't work with the C(state) set to absent. + type: bool + default: no + repository: + description: + - Name of the custom repository to use. + type: str + username: + description: + - Username to authenticate against private repository. + type: str + required: no + version_added: '1.10.0' + password: + description: + - Password to authenticate against private repository. + type: str + required: no + version_added: '1.10.0' + accept_license: + description: + - Accepts the module's license. + - Required for modules that require license acceptance, since interactively answering the prompt is not possible. + - Corresponds to the C(-AcceptLicense) parameter of C(Install-Module). + - >- + Installation of a module or a dependency that requires license acceptance cannot be detected in check mode, + but will cause a failure at runtime unless I(accept_license=true). + type: bool + required: no + default: false + version_added: '1.11.0' + url: + description: + - URL of the custom repository to register. + - DEPRECATED, will be removed in a major release after C(2021-07-01), please use the + M(community.windows.win_psrepository) module instead. + type: str +notes: + - PowerShell modules needed + - PowerShellGet >= 1.6.0 + - PackageManagement >= 1.1.7 + - PowerShell package provider needed + - NuGet >= 2.8.5.201 + - On PowerShell 5.x required modules and a package provider will be updated under the first run of the win_psmodule module. + - On PowerShell 3.x and 4.x you have to install them before using the win_psmodule. +seealso: +- module: community.windows.win_psrepository +author: +- Wojciech Sciesinski (@it-praktyk) +- Daniele Lazzari (@dlazz) +''' + +EXAMPLES = r''' +--- +- name: Add a PowerShell module + community.windows.win_psmodule: + name: PowerShellModule + state: present + +- name: Add an exact version of PowerShell module + community.windows.win_psmodule: + name: PowerShellModule + required_version: "4.0.2" + state: present + +- name: Install or update an existing PowerShell module to the newest version + community.windows.win_psmodule: + name: PowerShellModule + state: latest + +- name: Install newer version of built-in Windows module + community.windows.win_psmodule: + name: Pester + skip_publisher_check: yes + state: present + +- name: Add a PowerShell module and register a repository + community.windows.win_psmodule: + name: MyCustomModule + repository: MyRepository + state: present + +- name: Add a PowerShell module from a specific repository + community.windows.win_psmodule: + name: PowerShellModule + repository: MyRepository + state: present + +- name: Add a PowerShell module from a specific repository with credentials + win_psmodule: + name: PowerShellModule + repository: MyRepository + username: repo_username + password: repo_password + state: present + +- name: Remove a PowerShell module + community.windows.win_psmodule: + name: PowerShellModule + state: absent +''' + +RETURN = r''' +--- +output: + description: A message describing the task result. + returned: always + sample: "Module PowerShellCookbook installed" + type: str +nuget_changed: + description: True when Nuget package provider is installed. + returned: always + type: bool + sample: true +repository_changed: + description: True when a custom repository is installed or removed. + returned: always + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psmodule_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_psmodule_info.ps1 new file mode 100644 index 00000000..b0932a79 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psmodule_info.ps1 @@ -0,0 +1,305 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.CamelConversion +#Requires -Module PowerShellGet + +$spec = @{ + options = @{ + name = @{ type = 'str' ; default = '*' } + repository = @{ type = 'str' } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +# We need to remove this type data so that arrays don't get serialized weirdly. +# In some cases, an array gets serialized as an object with a Count and Value property where the value is the actual array. +# See: https://stackoverflow.com/a/48858780/3905079 +Remove-TypeData System.Array + +function Convert-ObjectToSnakeCase { + <# + .SYNOPSIS + Converts an object with CamelCase properties to a dictionary with snake_case keys. + Works in the spirit of and depends on the existing CamelConversion module util. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [Object] + $InputObject , + + [Parameter()] + [Switch] + $NoRecurse , + + [Parameter()] + [Switch] + $OmitNull + ) + + Process { + $result = [Ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $value = $property.Value + if (-not $NoRecurse -and $value -is [System.Collections.IDictionary]) { + $value = Convert-DictToSnakeCase -dict $value + } + elseif (-not $NoRecurse -and ($value -is [Array] -or $value -is [System.Collections.ArrayList])) { + $value = Convert-ListToSnakeCase -list $value + } + elseif ($null -eq $value) { + if ($OmitNull) { + continue + } + } + elseif (-not $NoRecurse -and $value -isnot [System.ValueType] -and $value -isnot [string]) { + $value = Convert-ObjectToSnakeCase -InputObject $value + } + + $name = Convert-StringToSnakeCase -string $property.Name + $result[$name] = $value + } + $result + } +} + +function ConvertTo-SerializableModuleInfo { + <# + .SYNOPSIS + Transforms some members of a ModuleInfo object to be more serialization-friendly and prevent infinite recursion. + + .DESCRIPTION + Stringifies version properties so we don't get serialized [System.Version] objects which aren't very useful. + + ExportedCommands and some other Exported* members are problematic in a few ways. + For one, they contain a reference to the full ModuleInfo object they are found in. This is probably useful + for nested modules and such, but in most cases they are just a reference to the current module, and this will + recurse infinitely. + + Further, the rest of the properties of the exported commands aren't needed for this module. A low depth on JSON + conversion doesn't fully work because some other module fields need it deeper than 1, and since this is a + dictionary we get a JSOn object whose property names and values are both just the name of the command. + + So instead we just make an array of the names, as that's good enough for what we want to return. + + NestedModules and RequiredModules are full ModuleInfo objects so that becomes a recursive call. + Limiting depth or reference counting shouldn't be necessary because the entries aren't references; they + have to actually exist. + + ModuleList gets recursed as well even though it's a tiny subset of fields, to transform versions and to + convert to snake_case. + + PrivateData can contain any data but a module manifest is a static file that can't contain references or + problematic types like [Type]. Unfortunately some module types like CIM (and presumably binary?) seem to be + able to populate that with whatever they want. + + As a precaution then, for module type that is not Script or Manifest, we limit PrivateData to a shallow depth. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Object] + $InputObject , + + [Parameter()] + [string[]] + $ExcludeProperty = @( + <# + Definition is the script that makes up the script module. + Big to return from this module, but also it doesn't ever seem to be filled in + in the data from Get-Module -ListAvailable. + #> + 'Definition', + <# + OnRemove is a script that gets run before the module is removed. + Not necessary for return. + #> + 'OnRemove', + <# + For binary modules, ImplementingAssembly is a reference to the assembly. + No use returning it. + #> + 'ImplementingAssembly', + <# + The session state instance of a loaded module. It's a runtime property only returned + without -ListAvailable and has no value serialized. + #> + 'SessionState' + ) + ) + + Process { + $properties = foreach ($p in $InputObject.PSObject.Properties) { + $pName = $p.Name + $pValue = $p.Value + + switch -Regex ($pName) { + '^PrivateData$' { + if ( + $InputObject.ModuleType -notin @( + [System.Management.Automation.ModuleType]::Script, + [System.Management.Automation.ModuleType]::Manifest + ) + ) { + $safeVal = $pValue | ConvertTo-Json -Depth 1 | ConvertFrom-Json + @{ + Name = $pName + Expression = { $safeVal }.GetNewClosure() + } + } + else { + $pName + } + break + } + '^(?:NestedModules|ModuleList|RequiredModules)$' { + # Nested and Required modules are full moduleinfo objects + # ModuleList isn't but its simplified fields need much of the same treatment + if ($pValue) { + @{ + Name = $pName + Expression = { + @(, ($pValue | ConvertTo-SerializableModuleInfo | Convert-ObjectToSnakeCase -NoRecurse)) + }.GetNewClosure() + } + } + else { + $pName + } + break + } + 'Version$' { + @{ + Name = $pName + Expression = { $pValue.ToString() }.GetNewClosure() + } + break + } + '^Exported' { + @{ + Name = $pName + Expression = { @(, $pValue.Keys) }.GetNewClosure() + } + break + } + default { + if ($pValue -is [datetime]) { + @{ + Name = $pName + Expression = { $pValue.ToString('o') }.GetNewClosure() + } + } + elseif ($pValue -is [enum] -or $pValue -is [type]) { + @{ + Name = $pName + Expression = { $pValue.ToString() }.GetNewClosure() + } + } + else { + $pName + } + } + } + } + + $InputObject | Select-Object -Property $properties -ExcludeProperty $ExcludeProperty + } +} + +function Add-ModuleRepositoryInfo { + <# + .SYNOPSIS + Takes a ModuleInfo object and adds some info that came from PowerShellGet + + .DESCRIPTION + Checks if there's information from Get-InstalledModule about the current module, + and if so adds to it to ModuleInfo object. The fields are always added, with null + values if need be. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Object] + $InputObject , + + [Parameter()] + [String] + $RepositoryName + ) + + Begin { + $wantedProperties = @( + 'PublishedDate', + 'InstalledDate', + 'UpdatedDate', + 'Dependencies', + 'Repository', + 'PackageManagementProvider', + 'InstalledLocation', + 'RepositorySourceLocation', + 'Tags', + 'CompatiblePSEditions', + 'LicenseUri', + 'ProjectUri', + 'IconUri', + 'ReleaseNotes', + 'ExportedDscResources', # ExportedDscResources is not returned here, this is a hack for Windows 2012/R2 to ensure the field is present + 'Prefix' # Prefix is not actually returned here, this is a hack for Windows 2012 just to ensure the field is present + ) + + # Get all the installed modules at once. This prevents us from having to make an expensive individual call for every + # local module, the vast majority of which didn't come from PowerShellGet. + # The results here won't contain every version, but that's ok because we'll still have a fast signal as to whether + # it makes sense to make a version-specific call for the individual module. + $installedModules = Get-InstalledModule -ErrorAction SilentlyContinue | Group-Object -Property Name -AsHashTable -AsString + } + + Process { + $moduleName = $InputObject.Name + + $installed = if ($installedModules.Contains($moduleName)) { + # we know at least one version of this module was installed from PowerShellGet + # if the version of this local modle matches what we got it in the initial installed module list + # use it + if ($installedModules[$moduleName].Version -eq $InputObject.Version) { + $installedModules[$moduleName] + } + else { + # make an individual call to see if this specific version of the local module was installed from PowerShellGet + $im = Get-InstalledModule -Name $InputObject.Name -RequiredVersion $InputObject.Version -AllowPrerelease -ErrorAction SilentlyContinue + if ($im) { + $im + } + } + } + + if ($RepositoryName -and $installed.Repository -ne $RepositoryName) { + return + } + + $members = @{} + $wantedProperties | ForEach-Object -Process { + if (-not $InputObject.$_) { + # if the fields are present in both places, let's prefer what's sent in + $members[$_] = $installed.$_ + } + } + + $InputObject | Add-Member -NotePropertyMembers $members -Force -PassThru + } +} + +$module.Result.modules = @( + Get-Module -ListAvailable -Name $module.Params.name | + Add-ModuleRepositoryInfo -RepositoryName $module.Params.repository | + ConvertTo-SerializableModuleInfo | + Convert-ObjectToSnakeCase -NoRecurse +) + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psmodule_info.py b/ansible_collections/community/windows/plugins/modules/win_psmodule_info.py new file mode 100644 index 00000000..4fe7c0c6 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psmodule_info.py @@ -0,0 +1,435 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psmodule_info +short_description: Gather information about PowerShell Modules +description: + - Gather information about PowerShell Modules including information from PowerShellGet. +options: + name: + description: + - The name of the module to retrieve. + - Supports any wildcard pattern supported by C(Get-Module). + - If omitted then all modules will returned. + type: str + default: '*' + repository: + description: + - The name of the PSRepository the modules were installed from. + - This acts as a filter against the modules that would be returned based on the I(name) option. + - Modules that were not installed from a repository will not be returned if this option is set. + - Only modules installed from a registered repository will be returned. + - If the repository was re-registered after module installation with a new C(SourceLocation), this will not match. + type: str +requirements: + - C(PowerShellGet) module +seealso: + - module: community.windows.win_psrepository_info + - module: community.windows.win_psscript_info +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Get info about all modules on the system + community.windows.win_psmodule_info: + +- name: Get info about the ScheduledTasks module + community.windows.win_psmodule_info: + name: ScheduledTasks + +- name: Get info about networking modules + community.windows.win_psmodule_info: + name: Net* + +- name: Get info about all modules installed from the PSGallery repository + community.windows.win_psmodule_info: + repository: PSGallery + register: gallery_modules + +- name: Update all modules retrieved from above example + community.windows.win_psmodule: + name: "{{ item }}" + state: latest + loop: "{{ gallery_modules.modules | map(attribute=name) }}" + +- name: Get info about all modules on the system + community.windows.win_psmodule_info: + register: all_modules + +- name: Find modules installed from a repository that isn't registered now + set_fact: + missing_repository_modules: "{{ + all_modules + | json_query('modules[?repository!=null && repository==repository_source_location].{name: name, version: version, repository: repository}') + | list + }}" + +- debug: + var: missing_repository_modules +''' + +RETURN = r''' +modules: + description: + - A list of modules (or an empty list is there are none). + returned: always + type: list + elements: dict + contains: + name: + description: + - The name of the module. + type: str + sample: PSReadLine + version: + description: + - The module version. + type: str + sample: 1.2.3 + guid: + description: + - The GUID of the module. + type: str + sample: 74c9fd30-734b-4c89-a8ae-7727ad21d1d5 + path: + description: + - The path to the module. + type: str + sample: 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\PKI\PKI.psd1' + module_base: + description: + - The path that contains the module's files. + type: str + sample: 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\PKI' + installed_location: + description: + - The path where the module is installed. + - This should have the same value as C(module_base) but only has a value when the module was installed via PowerShellGet. + type: str + sample: 'C:\Program Files\WindowsPowerShell\Modules\posh-git\0.7.1' + exported_aliases: + description: + - The aliases exported from the module. + type: list + elements: str + sample: + - glu + - slu + exported_cmdlets: + description: + - The cmdlets exported from the module. + type: list + elements: str + sample: + - Get-Certificate + - Get-PfxData + exported_commands: + description: + - All of the commands exported from the module. Includes functions, cmdlets, and aliases. + type: list + elements: str + sample: + - glu + - Get-LocalUser + exported_dsc_resources: + description: + - The DSC resources exported from the module. + type: list + elements: str + sample: + - xWebAppPool + - xWebSite + exported_format_files: + description: + - The format files exported from the module. + type: list + elements: path + sample: + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsCmdlets.Format.ps1xml' + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsConfig.Format.ps1xml' + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsClientPSProvider.Format.ps1xml' + exported_functions: + description: + - The functions exported from the module. + type: list + elements: str + sample: + - New-VirtualDisk + - New-Volume + exported_type_files: + description: + - The type files exported from the module. + type: list + elements: path + sample: + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsCmdlets.Types.ps1xml' + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsConfig.Types.ps1xml' + - 'C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules\DnsClient\DnsClientPSProvider.Types.ps1xml' + exported_variables: + description: + - The variables exported from the module. + type: list + elements: str + sample: + - GitPromptScriptBlock + exported_workflows: + description: + - The workflows exported from the module. + type: list + elements: str + access_mode: + description: + - The module's access mode. See U(https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.moduleaccessmode) + type: str + sample: ReadWrite + module_type: + description: + - The module's type. See U(https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.moduletype) + type: str + sample: Script + procoessor_architecture: + description: + - The module's processor architecture. See U(https://docs.microsoft.com/en-us/dotnet/api/system.reflection.processorarchitecture) + type: str + sample: Amd64 + author: + description: + - The author of the module. + type: str + sample: Warren Frame + copyright: + description: + - The copyright of the module. + type: str + sample: '(c) 2016 Warren F. All rights reserved.' + company_name: + description: + - The company name of the module. + type: str + sample: Microsoft Corporation + description: + description: + - The description of the module. + type: str + sample: Provides cmdlets to work with local users and local groups + clr_version: + description: + - The CLR version of the module. + type: str + sample: '4.0' + compatible_ps_editions: + description: + - The PS Editions the module is compatible with. + type: list + elements: str + sample: + - Desktop + dependencies: + description: + - The modules required by this module. + type: list + elements: str + dot_net_framework_version: + description: + - The .Net Framework version of the module. + type: str + sample: '4.6.1' + file_list: + description: + - The files included in the module. + type: list + elements: path + sample: + - 'C:\Program Files\WindowsPowerShell\Modules\PowerShellGet\1.6.0\PSModule.psm1' + - 'C:\Program Files\WindowsPowerShell\Modules\PowerShellGet\1.6.0\PSGet.Format.ps1xml' + - 'C:\Program Files\WindowsPowerShell\Modules\PowerShellGet\1.6.0\PSGet.Resource.psd1' + help_info_uri: + description: + - The help info address of the module. + type: str + sample: 'https://go.microsoft.com/fwlink/?linkid=390823' + icon_uri: + description: + - The address of the icon of the module. + type: str + sample: 'https://raw.githubusercontent.com/powershell/psscriptanalyzer/master/logo.png' + license_uri: + description: + - The address of the license for the module. + type: str + sample: 'https://github.com/PowerShell/xPendingReboot/blob/master/LICENSE' + project_uri: + description: + - The address of the module's project. + type: str + sample: 'https://github.com/psake/psake' + repository_source_location: + description: + - The source location of the repository where the module was installed from. + type: str + sample: 'https://www.powershellgallery.com/api/v2' + repository: + description: + - The PSRepository where the module was installed from. + - This value is not historical. It depends on the PSRepositories that are registered now for the current user. + - The C(repository_source_location) must match the current source location of a registered repository to get a repository name. + - If there is no match, then this value will match C(repository_source_location). + type: str + sample: PSGallery + release_notes: + description: + - The module's release notes. This is a free text field and no specific format should be assumed. + type: str + sample: | + ## 1.4.6 + - Update `HelpInfoUri` to point to the latest content + + ## 1.4.5 + - Bug fix for deadlock when getting parameters in an event + + ## 1.4.4 + - Bug fix when installing modules from private feeds + installed_date: + description: + - The date the module was installed. + type: str + sample: '2018-02-14T17:55:34.9620740-05:00' + published_date: + description: + - The date the module was published. + type: str + sample: '2017-03-15T04:18:09.0000000' + updated_date: + description: + - The date the module was last updated. + type: str + sample: '2019-12-31T09:20:02.0000000' + log_pipeline_execution_details: + description: + - Determines whether pipeline execution detail events should be logged. + type: bool + module_list: + description: + - A list of modules packaged with this module. + - This value is not often returned and the modules are not automatically processed. + type: list + elements: dict + contains: + name: + description: + - The name of the module. + - This may also be a path to the module file. + type: str + sample: '.\WindowsUpdateLog.psm1' + guid: + description: + - The GUID of the module. + type: str + sample: 82fdb72c-ecc5-4dfd-b9d5-83cf6eb9067f + version: + description: + - The minimum version of the module. + type: str + sample: '2.0' + maximum_version: + description: + - The maximum version of the module. + type: str + sample: '2.9' + required_version: + description: + - The exact version of the module required. + type: str + sample: '3.1.4' + nested_modules: + description: + - A list of modules nested with and loaded into the scope of this module. + - This list contains full module objects, so each item can have all of the properties listed here, including C(nested_modules). + type: list + elements: dict + required_modules: + description: + - A list of modules required by this module. + - This list contains full module objects, so each item can have all of the properties listed here, including C(required_modules). + - These module objects may not contain full information however, so you may see different results than if you had directly queried the module. + type: list + elements: dict + required_assemblies: + description: + - A list of assemblies that the module requires. + - The values may be a simple name or a full path. + type: str + sample: + - Microsoft.Management.Infrastructure.CimCmdlets.dll + - Microsoft.Management.Infrastructure.Dll + package_management_provider: + description: + - If the module was installed from PowerShellGet, this is the package management provider used. + type: str + sample: NuGet + power_shell_host_name: + description: + - The name of the PowerShell host that the module requires. + type: str + sample: Windows PowerShell ISE Host + power_shell_host_version: + description: + - The version of the PowerShell host that the module requires. + type: str + sample: '1.1' + power_shell_version: + description: + - The minimum version of PowerShell that the module requires. + type: str + sample: '5.1' + prefix: + description: + - The default prefix applied to C(Verb-Noun) commands exported from the module, resulting in C(Verb-PrefixNoun) names. + type: str + private_data: + description: + - Arbitrary private data used by the module. This is typically defined in the module manifest. + - This module limits the depth of the data returned for module types other than C(Script) and C(Manifest). + - The C(PSData) is commonly supplied and provides metadata for PowerShellGet but those fields are surfaced in top-level properties as well. + type: dict + sample: + PSData: + LicenseUri: https://example.com/module/LICENSE + ProjectUri: https://example.com/module/ + ReleaseNotes: | + v2 - Fixed some bugs + v1 - First release + Tags: + - networking + - serialization + root_module: + description: + - The root module as defined in the manifest. + - This may be a module name, filename, or full path. + type: str + sample: WindowsErrorReporting.psm1 + scripts: + description: + - A list of scripts (C(.ps1) files) that run in the caller's session state when the module is imported. + - This value comes from the C(ScriptsToProcess) field in the module's manifest. + type: list + sample: + - PrepareEnvironment.ps1 + - InitializeData.ps1 + tags: + description: + - The tags defined in the module's C(PSData) metadata. + type: list + elements: str + sample: + - networking + - serialization + - git + - dsc +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository.ps1 b/ansible_collections/community/windows/plugins/modules/win_psrepository.ps1 new file mode 100644 index 00000000..6e3319fd --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository.ps1 @@ -0,0 +1,179 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# Copyright: (c) 2018, Wojciech Sciesinski <wojciech[at]sciesinski[dot]net> +# Copyright: (c) 2017, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Some vars are referenced via Get-Variable')] +param() # param() is needed for attribute to take effect. + +# win_psrepository (Windows PowerShell repositories Additions/Removals/Updates) + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$source_location = Get-AnsibleParam -obj $params -name "source_location" -aliases "source" -type "str" +$script_source_location = Get-AnsibleParam -obj $params -name "script_source_location" -type "str" +$publish_location = Get-AnsibleParam -obj $params -name "publish_location" -type "str" +$script_publish_location = Get-AnsibleParam -obj $params -name "script_publish_location" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present", "absent" +$installation_policy = Get-AnsibleParam -obj $params -name "installation_policy" -type "str" -validateset "trusted", "untrusted" +$force = Get-AnsibleParam -obj $params -name "force" -type "bool" -default $false +$proxy = Get-AnsibleParam -obj $params -name "proxy" -type "str" -failifempty $false +$repo_user = Get-AnsibleParam -obj $params -name "username" -type "str" +$repo_pass = Get-AnsibleParam -obj $params -name "password" -type "str" + +$result = @{"changed" = $false } + +# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5) +$security_protocols = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::SystemDefault +if ([System.Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls11 +} +if ([System.Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls12 +} +[System.Net.ServicePointManager]::SecurityProtocol = $security_protocols + +if (-not (Import-Module -Name PowerShellGet -MinimumVersion 1.6.0 -PassThru -ErrorAction SilentlyContinue)) { + Fail-Json -obj $result -Message "PowerShellGet version 1.6.0+ is required." +} + +$repository_params = @{ + Name = $name +} + +$Repo = Get-PSRepository @repository_params -ErrorAction SilentlyContinue + +if ($installation_policy) { + $repository_params.InstallationPolicy = $installation_policy +} + +if ($proxy) { + $repository_params.Proxy = $Proxy +} + +if ($repo_user -and $repo_pass ) { + $repository_params.Credential = New-Object -TypeName PSCredential ($repo_user, ($repo_pass | ConvertTo-SecureString -AsPlainText -Force)) +} + +function Resolve-LocationParameter { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string[]] + $Name , + + [Parameter(Mandatory = $true)] + [hashtable] + $Splatter + ) + + End { + foreach ($param in $Name) { + $val = Get-Variable -Name $param -ValueOnly -ErrorAction SilentlyContinue + if ($val) { + if ($val -as [uri]) { + $Splatter[$param.Replace('_', '')] = $val -as [uri] + } + else { + Fail-Json -obj $result -Message "'$param' must be a valid URI." + } + } + } + } +} + +Resolve-LocationParameter -Name source_location, publish_location, script_source_location, script_publish_location -Splatter $repository_params + +if (-not $repository_params.SourceLocation -and $state -eq 'present' -and ($force -or -not $Repo)) { + Fail-Json -obj $result -message "'source_location' is required when registering a new repository or using force with 'state' == 'present'." +} +Function Update-NuGetPackageProvider { + $PackageProvider = Get-PackageProvider -ListAvailable | Where-Object { ($_.name -eq 'Nuget') -and ($_.version -ge "2.8.5.201") } + if ($null -eq $PackageProvider) { + Find-PackageProvider -Name Nuget -ForceBootstrap -IncludeDependencies -Force | Out-Null + } +} + +if ($Repo) { + $changed_properties = @{} + + if ($force -and -not $repository_params.InstallationPolicy) { + $repository_params.InstallationPolicy = 'trusted' + } + + if ($repository_params.InstallationPolicy) { + if ($Repo.InstallationPolicy -ne $repository_params.InstallationPolicy) { + $changed_properties.InstallationPolicy = $repository_params.InstallationPolicy + } + } + + if ($repository_params.SourceLocation) { + # force check not needed here because it's done earlier; source_location is required with force + if ($repository_params.SourceLocation -ne $Repo.SourceLocation) { + $changed_properties.SourceLocation = $repository_params.SourceLocation + } + } + + if ($force -or $repository_params.ScriptSourceLocation) { + if ($repository_params.ScriptSourceLocation -ne $Repo.ScriptSourceLocation) { + $changed_properties.ScriptSourceLocation = $repository_params.ScriptSourceLocation + } + } + + if ($force -or $repository_params.PublishLocation) { + if ($repository_params.PublishLocation -ne $Repo.PublishLocation) { + $changed_properties.PublishLocation = $repository_params.PublishLocation + } + } + + if ($force -or $repository_params.ScriptPublishLocation) { + if ($repository_params.ScriptPublishLocation -ne $Repo.ScriptPublishLocation) { + $changed_properties.ScriptPublishLocation = $repository_params.ScriptPublishLocation + } + } + + if ($changed_properties.Count -gt 0) { + if ($repository_params.Credential) { + $changed_properties.Credential = $repository_params.Credential + } + } +} + +if ($Repo -and ($state -eq "absent" -or ($force -and $changed_properties.Count -gt 0))) { + if (-not $check_mode) { + Update-NuGetPackageProvider + Unregister-PSRepository -Name $name + } + $result.changed = $true +} + +if ($state -eq "present") { + if (-not $Repo -or ($force -and $changed_properties.Count -gt 0)) { + if (-not $repository_params.InstallationPolicy) { + $repository_params.InstallationPolicy = "trusted" + } + if (-not $check_mode) { + Update-NuGetPackageProvider + Register-PSRepository @repository_params + } + $result.changed = $true + } + else { + if ($changed_properties.Count -gt 0) { + if (-not $check_mode) { + Update-NuGetPackageProvider + Set-PSRepository -Name $name @changed_properties + } + $result.changed = $true + } + } +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository.py b/ansible_collections/community/windows/plugins/modules/win_psrepository.py new file mode 100644 index 00000000..6c37b204 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository.py @@ -0,0 +1,153 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# Copyright: (c) 2018, Wojciech Sciesinski <wojciech[at]sciesinski[dot]net> +# Copyright: (c) 2017, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psrepository +short_description: Adds, removes or updates a Windows PowerShell repository. +description: + - This module helps to add, remove and update Windows PowerShell repository on Windows-based systems. +options: + name: + description: + - Name of the repository to work with. + type: str + required: yes + source_location: + description: + - Specifies the URI for discovering and installing modules from this repository. + - A URI can be a NuGet server feed (most common situation), HTTP, HTTPS, FTP or file location. + - Required when registering a new repository or using I(force=True). + type: str + aliases: + - source + script_source_location: + description: + - Specifies the URI for discovering and installing scripts from this repository. + type: str + publish_location: + description: + - Specifies the URI for publishing modules to this repository. + type: str + script_publish_location: + description: + - Specifies the URI for publishing scripts to this repository. + type: str + state: + description: + - If C(present) a new repository is added or updated. + - If C(absent) a repository is removed. + type: str + choices: [ absent, present ] + default: present + installation_policy: + description: + - Sets the C(InstallationPolicy) of a repository. + - Will default to C(trusted) when creating a new repository or used with I(force=True). + type: str + choices: [ trusted, untrusted ] + force: + description: + - If C(True), any differences from the desired state will result in the repository being unregistered, and then re-registered. + - I(force) has no effect when I(state=absent). See notes for additional context. + type: bool + default: False + proxy: + description: + - Proxy to use for repository. + type: str + required: no + version_added: 1.1.0 + username: + description: + - Username to authenticate against private repository. + type: str + required: no + version_added: '1.10.0' + password: + description: + - Password to authenticate against private repository. + type: str + required: no + version_added: '1.10.0' +requirements: + - PowerShell Module L(PowerShellGet >= 1.6.0,https://www.powershellgallery.com/packages/PowerShellGet/) + - PowerShell Module L(PackageManagement >= 1.1.7,https://www.powershellgallery.com/packages/PackageManagement/) + - PowerShell Package Provider C(NuGet) >= 2.8.5.201 +notes: + - See the examples on how to update the NuGet package provider. + - You can not use C(win_psrepository) to re-register (add) removed PSGallery, use the command C(Register-PSRepository -Default) instead. + - When registering or setting I(source_location), PowerShellGet will transform the location according to internal rules, such as following HTTP/S redirects. + - This can result in a C(CHANGED) status on each run as the values will never match and will be "reset" each time. + - To work around that, find the true destination value with M(community.windows.win_psrepository_info) or C(Get-PSRepository) and update the playbook to + match. + - When updating an existing repository, all options except I(name) are optional. Only supplied options will be updated. Use I(force=True) to exactly match. + - I(script_location), I(publish_location), and I(script_publish_location) are optional but once set can only be cleared with I(force=True). + - Using I(force=True) will unregister and re-register the repository if there are any changes, so that it exactly matches the options specified. +seealso: + - module: community.windows.win_psrepository_info + - module: community.windows.win_psmodule +author: + - Wojciech Sciesinski (@it-praktyk) + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +--- +- name: Ensure the required NuGet package provider version is installed + ansible.windows.win_shell: Find-PackageProvider -Name Nuget -ForceBootstrap -IncludeDependencies -Force + +- name: Register a PowerShell repository + community.windows.win_psrepository: + name: MyRepository + source_location: https://myrepo.com + state: present + +- name: Remove a PowerShell repository + community.windows.win_psrepository: + name: MyRepository + state: absent + +- name: Add an untrusted repository + community.windows.win_psrepository: + name: MyRepository + installation_policy: untrusted + +- name: Add a repository with different locations + community.windows.win_psrepository: + name: NewRepo + source_location: https://myrepo.example/module/feed + script_source_location: https://myrepo.example/script/feed + publish_location: https://myrepo.example/api/module/publish + script_publish_location: https://myrepo.example/api/script/publish + +- name: Update only two properties on the above repository + community.windows.win_psrepository: + name: NewRepo + installation_policy: untrusted + script_publish_location: https://scriptprocessor.example/publish + +- name: Clear script locations from the above repository by re-registering it + community.windows.win_psrepository: + name: NewRepo + installation_policy: untrusted + source_location: https://myrepo.example/module/feed + publish_location: https://myrepo.example/api/module/publish + force: True + +- name: Register a PowerShell repository with credentials + community.windows.win_psrepository: + name: MyRepository + source_location: https://myrepo.com + state: present + username: repo_username + password: repo_password +''' + +RETURN = ''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.ps1 b/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.ps1 new file mode 100644 index 00000000..47c6cece --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.ps1 @@ -0,0 +1,233 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + supports_check_mode = $true + options = @{ + source = @{ + type = 'path' + default = '%LOCALAPPDATA%\Microsoft\Windows\PowerShell\PowerShellGet\PSRepositories.xml' + } + name = @{ + type = 'list' + elements = 'str' + default = @( + '*' + ) + } + exclude = @{ + type = 'list' + elements = 'str' + } + profiles = @{ + type = 'list' + elements = 'str' + default = @( + '*' + ) + } + exclude_profiles = @{ + type = 'list' + elements = 'str' + default = @( + 'systemprofile' + 'LocalService' + 'NetworkService' + ) + } + } +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Diff.before = @{} +$module.Diff.after = @{} + +function Select-Wildcard { + <# + .SYNOPSIS + Compares a value to an Include and Exclude list of wildcards, + returning the input object if a match is found + + .DESCRIPTION + If $Property is specified, that property of the input object is + compared rather than the object itself, but the original object + is returned, not the property. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [object] + $InputObject , + + [Parameter()] + [String] + $Property , + + [Parameter()] + [String[]] + $Include , + + [Parameter()] + [String[]] + $Exclude + ) + + Process { + $o = if ($Property) { + $InputObject.($Property) + } + else { + $InputObject + } + + foreach ($inc in $Include) { + $imatch = $o -like $inc + if ($imatch) { + break + } + } + + if (-not $imatch) { + return + } + + foreach ($exc in $Exclude) { + if ($o -like $exc) { + return + } + } + + $InputObject + } +} + +function Get-ProfileDirectory { + <# + .SYNOPSIS + Returns DirectoryInfo objects for each profile on the system, as reported by the registry + + .DESCRIPTION + The special "Default" profile, used as a template for newly created users, is explicitly + added to the list of possible profiles returned. Public is explicitly excluded. + Paths reported by the registry that don't exist on the filesystem are silently skipped. + #> + [CmdletBinding()] + [OutputType([System.IO.DirectoryInfo])] + param( + [Parameter()] + [String[]] + $Include , + + [Parameter()] + [String[]] + $Exclude + ) + + $regPL = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + + # note: this is a key named "Default", not the (Default) key + $default = Get-ItemProperty -LiteralPath $regPL | Select-Object -ExpandProperty Default + + # "ProfileImagePath" is always the local side of the profile, even if roaming profiles are used + # This is what we want, because PSRepositories are stored in AppData/Local and don't roam + $profiles = ( + @($default) + + (Get-ChildItem -LiteralPath $regPL | Get-ItemProperty | Select-Object -ExpandProperty ProfileImagePath) + ) -as [System.IO.DirectoryInfo[]] + + $profiles | + Where-Object -Property Exists -EQ $true | + Select-Wildcard -Property Name -Include $Include -Exclude $Exclude +} + +function Compare-Hashtable { + <# + .SYNOPSIS + Attempts to naively compare two hashtables by serializing them and string comparing the serialized versions + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [hashtable] + $ReferenceObject , + + [Parameter(Mandatory)] + [hashtable] + $DifferenceObject , + + [Parameter()] + [int] + $Depth + ) + + if ($PSBoundParameters.ContainsKey('Depth')) { + $sRef = [System.Management.Automation.PSSerializer]::Serialize($ReferenceObject, $Depth) + $sDif = [System.Management.Automation.PSSerializer]::Serialize($DifferenceObject, $Depth) + } + else { + $sRef = [System.Management.Automation.PSSerializer]::Serialize($ReferenceObject) + $sDif = [System.Management.Automation.PSSerializer]::Serialize($DifferenceObject) + } + + $sRef -ceq $sDif +} + +# load the repositories from the source file +try { + $src = $module.Params.source + $src_repos = Import-Clixml -LiteralPath $src -ErrorAction Stop +} +catch [System.IO.FileNotFoundException] { + $module.FailJson("The source file '$src' was not found.", $_) +} +catch { + $module.FailJson("There was an error loading the source file '$src': $($_.Exception.Message).", $_) +} + +$profiles = Get-ProfileDirectory -Include $module.Params.profiles -Exclude $module.Params.exclude_profiles + +foreach ($user in $profiles) { + $username = $user.Name + + $repo_dir = $user.FullName | Join-Path -ChildPath 'AppData\Local\Microsoft\Windows\PowerShell\PowerShellGet' + $repo_path = $repo_dir | Join-Path -ChildPath 'PSRepositories.xml' + + if (Test-Path -LiteralPath $repo_path) { + $cur_repos = Import-Clixml -LiteralPath $repo_path + } + else { + $cur_repos = @{} + } + + $new_repos = $cur_repos.Clone() + $updated = $false + + $src_repos.Keys | + Select-Wildcard -Include $module.Params.name -Exclude $module.Params.exclude | + ForEach-Object -Process { + # explicit scope used inside ForEach-Object to satisfy lint (PSUseDeclaredVarsMoreThanAssignment) + # see https://github.com/PowerShell/PSScriptAnalyzer/issues/827 + $Script:updated = $true + $Script:new_repos[$_] = $Script:src_repos[$_] + } + + $module.Diff.before[$username] = $cur_repos + $module.Diff.after[$username] = $new_repos + + if ($updated -and -not (Compare-Hashtable -ReferenceObject $cur_repos -DifferenceObject $new_repos)) { + if (-not $module.CheckMode) { + $null = New-Item -Path $repo_dir -ItemType Directory -Force -ErrorAction SilentlyContinue + $new_repos | Export-Clixml -LiteralPath $repo_path -Force + } + $module.Result.changed = $true + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.py b/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.py new file mode 100644 index 00000000..686c0062 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository_copy.py @@ -0,0 +1,143 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psrepository_copy +short_description: Copies registered PSRepositories to other user profiles +version_added: '1.3.0' +description: + - Copies specified registered PSRepositories to other user profiles on the system. + - Can include the C(Default) profile so that new users start with the selected repositories. + - Can include special service accounts like the local SYSTEM user, LocalService, NetworkService. +options: + source: + description: + - The full path to the source repositories XML file. + - Defaults to the repositories registered to the current user. + type: path + default: '%LOCALAPPDATA%\Microsoft\Windows\PowerShell\PowerShellGet\PSRepositories.xml' + name: + description: + - The names of repositories to copy. + - Names are interpreted as wildcards. + type: list + elements: str + default: ['*'] + exclude: + description: + - The names of repositories to exclude. + - Names are interpreted as wildcards. + - If a name matches both an include (I(name)) and I(exclude), it will be excluded. + type: list + elements: str + profiles: + description: + - The names of user profiles to populate with repositories. + - Names are interpreted as wildcards. + - The C(Default) profile can also be matched. + - The C(Public) and C(All Users) profiles cannot be targeted, as PSRepositories are not loaded from them. + type: list + elements: str + default: ['*'] + exclude_profiles: + description: + - The names of user profiles to exclude. + - If a profile matches both an include (I(profiles)) and I(exclude_profiles), it will be excluded. + - By default, the service account profiles are excluded. + - To explcitly exclude nothing, set I(exclude_profiles=[]). + type: list + elements: str + default: + - systemprofile + - LocalService + - NetworkService +notes: + - Does not require the C(PowerShellGet) module or any other external dependencies. + - User profiles are loaded from the registry. If a given path does not exist (like if the profile directory was deleted), it is silently skipped. + - If setting service account profiles, you may need C(become=yes). See examples. + - "When PowerShellGet first sets up a repositories file, it always adds C(PSGallery), however if this module creates a new repos file and your selected + repositories don't include C(PSGallery), it won't be in your destination." + - "The values searched in I(profiles) (and I(exclude_profiles)) are profile names, not necessarily user names. This can happen when the profile path is + deliberately changed or when domain user names conflict with users from the local computer or another domain. In this case the second+ user may have the + domain name or local computer name appended, like C(JoeUser.Contoso) vs. C(JoeUser). + If you intend to filter user profiles, ensure your filters catch the right names." + - "In the case of the service accounts, the specific profiles are C(systemprofile) (for the C(SYSTEM) user), and C(LocalService) or C(NetworkService) + for those accounts respectively." + - "Repositories with credentials (requiring authentication) or proxy information will copy, but the credentials and proxy details will not as that + information is not stored with repository." +seealso: + - module: community.windows.win_psrepository + - module: community.windows.win_psrepository_info +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Copy the current user's PSRepositories to all non-service account profiles and Default profile + community.windows.win_psrepository_copy: + +- name: Copy the current user's PSRepositories to all profiles and Default profile + community.windows.win_psrepository_copy: + exclude_profiles: [] + +- name: Copy the current user's PSRepositories to all profiles beginning with A, B, or C + community.windows.win_psrepository_copy: + profiles: + - 'A*' + - 'B*' + - 'C*' + +- name: Copy the current user's PSRepositories to all profiles beginning B except Brian and Brianna + community.windows.win_psrepository_copy: + profiles: 'B*' + exclude_profiles: + - Brian + - Brianna + +- name: Copy a specific set of repositories to profiles beginning with 'svc' with exceptions + community.windows.win_psrepository_copy: + name: + - CompanyRepo1 + - CompanyRepo2 + - PSGallery + profiles: 'svc*' + exclude_profiles: 'svc-restricted' + +- name: Copy repos matching a pattern with exceptions + community.windows.win_psrepository_copy: + name: 'CompanyRepo*' + exclude: 'CompanyRepo*-Beta' + +- name: Copy repositories from a custom XML file on the target host + community.windows.win_psrepository_copy: + source: 'C:\data\CustomRepostories.xml' + +### A sample workflow of seeding a system with a custom repository + +# A playbook that does initial host setup or builds system images + +- name: Register custom respository + community.windows.win_psrepository: + name: PrivateRepo + source_location: https://example.com/nuget/feed/etc + installation_policy: trusted + +- name: Ensure all current and new users have this repository registered + community.windows.win_psrepository_copy: + name: PrivateRepo + +# In another playbook, run by other users (who may have been created later) + +- name: Install a module + community.windows.win_psmodule: + name: CompanyModule + repository: PrivateRepo + state: present +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_psrepository_info.ps1 new file mode 100644 index 00000000..c0a7545b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository_info.ps1 @@ -0,0 +1,68 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.CamelConversion +#Requires -Module PowerShellGet + +$spec = @{ + options = @{ + name = @{ type = 'str' ; default = '*' } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +function Convert-ObjectToSnakeCase { + <# + .SYNOPSIS + Converts an object with CamelCase properties to a dictionary with snake_case keys. + Works in the spirit of and depends on the existing CamelConversion module util. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [Object] + $InputObject , + + [Parameter()] + [Switch] + $NoRecurse , + + [Parameter()] + [Switch] + $OmitNull + ) + + Process { + $result = [Ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $value = $property.Value + if (-not $NoRecurse -and $value -is [System.Collections.IDictionary]) { + $value = Convert-DictToSnakeCase -dict $value + } + elseif (-not $NoRecurse -and ($value -is [Array] -or $value -is [System.Collections.ArrayList])) { + $value = Convert-ListToSnakeCase -list $value + } + elseif ($null -eq $value) { + if ($OmitNull) { + continue + } + } + elseif (-not $NoRecurse -and $value -isnot [System.ValueType] -and $value -isnot [string]) { + $value = Convert-ObjectToSnakeCase -InputObject $value + } + + $name = Convert-StringToSnakeCase -string $property.Name + $result[$name] = $value + } + $result + } +} + +$module.Result.repositories = @(Get-PSRepository -Name $module.Params.name | Convert-ObjectToSnakeCase) + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psrepository_info.py b/ansible_collections/community/windows/plugins/modules/win_psrepository_info.py new file mode 100644 index 00000000..9a54d041 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psrepository_info.py @@ -0,0 +1,107 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psrepository_info +short_description: Gather information about PSRepositories +description: + - Gather information about all or a specific PSRepository. +options: + name: + description: + - The name of the repository to retrieve. + - Supports any wildcard pattern supported by C(Get-PSRepository). + - If omitted then all repositories will returned. + type: str + default: '*' +requirements: + - C(PowerShellGet) module +seealso: + - module: community.windows.win_psrepository +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Get info for a single repository + community.windows.win_psrepository_info: + name: PSGallery + register: repo_info + +- name: Find all repositories that start with 'MyCompany' + community.windows.win_psrepository_info: + name: MyCompany* + +- name: Get info for all repositories + community.windows.win_psrepository_info: + register: repo_info + +- name: Remove all repositories that don't have a publish_location set + community.windows.win_psrepository: + name: "{{ item }}" + state: absent + loop: "{{ repo_info.repositories | rejectattr('publish_location', 'none') | list }}" +''' + +RETURN = r''' +repositories: + description: + - A list of repositories (or an empty list is there are none). + returned: always + type: list + elements: dict + contains: + name: + description: + - The name of the repository. + type: str + sample: PSGallery + installation_policy: + description: + - The installation policy of the repository. The sample values are the only possible values. + type: str + sample: + - Trusted + - Untrusted + trusted: + description: + - A boolean flag reflecting the value of C(installation_policy) as to whether the repository is trusted. + type: bool + package_management_provider: + description: + - The name of the package management provider for this repository. + type: str + sample: NuGet + provider_options: + description: + - Provider-specific options for this repository. + type: dict + source_location: + description: + - The location used to find and retrieve modules. This should always have a value. + type: str + sample: https://www.powershellgallery.com/api/v2 + publish_location: + description: + - The location used to publish modules. + type: str + sample: https://www.powershellgallery.com/api/v2/package/ + script_source_location: + description: + - The location used to find and retrieve scripts. + type: str + sample: https://www.powershellgallery.com/api/v2/items/psscript + script_publish_location: + description: + - The location used to publish scripts. + type: str + sample: https://www.powershellgallery.com/api/v2/package/ + registered: + description: + - Whether the module is registered. Should always be C(True) + type: bool +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psscript.ps1 b/ansible_collections/community/windows/plugins/modules/win_psscript.ps1 new file mode 100644 index 00000000..dc54741f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psscript.ps1 @@ -0,0 +1,198 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module @{ ModuleName = 'PowerShellGet' ; ModuleVersion = '1.6.0' } + +$spec = @{ + supports_check_mode = $true + options = @{ + name = @{ + type = 'str' + required = $true + } + repository = @{ type = 'str' } + scope = @{ + type = 'str' + choices = @('all_users', 'current_user') + default = 'all_users' + } + state = @{ + type = 'str' + choices = @('present', 'absent', 'latest') + default = 'present' + } + required_version = @{ type = 'str' } + minimum_version = @{ type = 'str' } + maximum_version = @{ type = 'str' } + source_username = @{ type = 'str' } + source_password = @{ + type = 'str' + no_log = $true + } + allow_prerelease = @{ + type = 'bool' + default = $false + } + } + + mutually_exclusive = @( + @('required_version', 'minimum_version'), + @('required_version', 'maximum_version') + ) + + required_together = @( + , @('source_username', 'source_password') + ) +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$state = $module.Params.state + +# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5) +$security_protocols = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::SystemDefault +if ([System.Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls11 +} +if ([System.Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { + $security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls12 +} +[System.Net.ServicePointManager]::SecurityProtocol = $security_protocols + +function Get-SplattableParameter { + [CmdletBinding(DefaultParameterSetName = 'All')] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [Ansible.Basic.AnsibleModule] + $Module , + + [Parameter(ParameterSetName = 'ForCommand')] + [Alias('For')] + [String] + $ForCommand , + + [Parameter(ParameterSetName = 'WithCommand', ValueFromPipeline = $true)] + [Alias('Command')] + [System.Management.Automation.CommandInfo] + $WithCommand + ) + + Process { + $ret = @{} + + $cmd = switch ($PSCmdlet.ParameterSetName) { + 'WithCommand' { $WithCommand } + 'ForCommand' { Get-Command -Name $ForCommand } + } + + if ($cmd) { + $commons = [System.Management.Automation.Cmdlet]::CommonParameters + [System.Management.Automation.Cmdlet]::CommonOptionalParameters + $validParams = $cmd.Parameters.GetEnumerator() | ForEach-Object -Process { + if ($commons -notcontains $_.Key) { + $_.Key + } + } + } + + switch -Wildcard ($Module.Params.Keys) { + '*' { + $value = $Module.Params[$_] + if ($null -eq $value) { + continue + } + + $key = $_.Replace('_', '') + if ($validParams -and $validParams -notcontains $key) { + continue + } + } + + 'scope' { $value = $value.Replace('_', '') } + 'source_username' { continue } # handled in password block + 'source_password' { + $key = 'Credential' + $secure = ConvertTo-SecureString -String $value -AsPlainText -Force + $value = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $Module.Params.source_username, $secure + } + '*_version' { + if ($Module.Params.state -eq 'latest') { + $Module.FailJson("version options can't be used with 'state': 'latest'") + } + } + 'repository' { + if ($Module.Params.state -eq 'absent') { + $Module.FailJson("'repository' is not valid with 'state': 'absent'") + } + } + + '*' { $ret[$key] = $value } + } + + $ret + } +} + +$pGet, $pUninstall, $pFind, $pInstall = Get-Command -Name @( + 'Get-InstalledScript' + , 'Uninstall-Script' + , 'Find-Script' + , 'Install-Script' +) | Get-SplattableParameter -Module $module + +$existing = Get-InstalledScript @pGet -ErrorAction SilentlyContinue +$existing = if ($existing) { + $existing | Group-Object -Property Name -AsHashTable -AsString +} +else { + @{} +} + +if ($state -eq 'absent') { + if ($existing.Count) { + try { + $module.Result.changed = $true + if (-not $module.CheckMode) { + $existing.Values | Uninstall-Script -Force -ErrorAction Stop + } + } + catch { + $module.FailJson("Error uninstalling scripts.", $_) + } + } +} +else { + # state is 'present' or 'latest' + try { + $remote = Find-Script @pFind -ErrorAction Stop + } + catch { + $module.FailJson("Error searching for scripts.", $_) + } + + try { + $toInstall = $remote | Where-Object -FilterScript { + -not $existing.ContainsKey($_.Name) -or ( + $state -eq 'latest' -and + ($_.Version -as [version]) -gt ($existing[$_.Name].Version -as [version]) + ) + } + + if (($toInstall | Group-Object -Property Name -NoElement | Where-Object -Property Count -gt 1)) { + $module.FailJson("Multiple scripts found. Please choose a specific repository.") + } + + $module.Result.changed = $toInstall -as [bool] + + if ($toInstall -and -not $module.CheckMode) { + $toInstall | Install-Script -Scope:$pInstall.scope -Force -ErrorAction Stop + } + } + catch { + $module.FailJson("Error installing scripts.", $_) + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psscript.py b/ansible_collections/community/windows/plugins/modules/win_psscript.py new file mode 100644 index 00000000..00269d03 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psscript.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psscript +short_description: Install and manage PowerShell scripts from a PSRepository +description: + - Add or remove PowerShell scripts from registered PSRepositories. +options: + name: + description: + - The name of the script you want to install or remove. + type: str + required: True + repository: + description: + - The registered name of the repository you want to install from. + - Cannot be used when I(state=absent). + - If ommitted, all repositories will be searched. + - To register a repository, use M(community.windows.win_psrepository). + type: str + scope: + description: + - Determines whether the script is installed for only the C(current_user) or for C(all_users). + type: str + choices: + - current_user + - all_users + default: all_users + state: + description: + - The desired state of the script. C(absent) removes the script. + - C(latest) will ensure the most recent version available is installed. + - C(present) only installs if the script is missing. + type: str + choices: + - present + - absent + - latest + default: present + required_version: + description: + - The exact version of the script to install. + - Cannot be used with I(minimum_version) or I(maximum_version). + - Cannot be used when I(state=latest). + type: str + minimum_version: + description: + - The minimum version of the script to install. + - Cannot be used when I(state=latest). + type: str + maximum_version: + description: + - The maximum version of the script to install. + - Cannot be used when I(state=latest). + type: str + allow_prerelease: + description: + - If C(yes) installs scripts flagged as prereleases. + type: bool + default: no + source_username: + description: + - The username portion of the credential required to access the repository. + - Must be used together with I(source_password). + type: str + source_password: + description: + - The password portion of the credential required to access the repository. + - Must be used together with I(source_username). + type: str +requirements: + - C(PowerShellGet) module v1.6.0+ +seealso: + - module: community.windows.win_psrepository + - module: community.windows.win_psrepository_info + - module: community.windows.win_psmodule +notes: + - Unlike PowerShell modules, scripts do not support side-by-side installations of multiple versions. Installing a new version will replace the existing one. +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Install a script from PSGallery + community.windows.win_psscript: + name: Test-RPC + repository: PSGallery + +- name: Find and install the latest version of a script from any repository + community.windows.win_psscript: + name: Get-WindowsAutoPilotInfo + state: latest + +- name: Remove a script that isn't needed + community.windows.win_psscript: + name: Defrag-Partition + state: absent + +- name: Install a specific version of a script for the current user + community.windows.win_psscript: + name: CleanOldFiles + scope: current_user + required_version: 3.10.2 + +- name: Install a script below a certain version + community.windows.win_psscript: + name: New-FeatureEnable + maximum_version: 2.99.99 + +- name: Ensure a minimum version of a script is present + community.windows.win_psscript: + name: OldStandby + minimum_version: 3.0.0 + +- name: Install any available version that fits a specific range + community.windows.win_psscript: + name: FinickyScript + minimum_version: 2.5.1 + maximum_version: 2.6.19 +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_psscript_info.ps1 b/ansible_collections/community/windows/plugins/modules/win_psscript_info.ps1 new file mode 100644 index 00000000..8f6cc908 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psscript_info.ps1 @@ -0,0 +1,129 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.CamelConversion +#Requires -Module PowerShellGet + +$spec = @{ + options = @{ + name = @{ type = 'str' ; default = '*' } + repository = @{ type = 'str' } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +function Convert-ObjectToSnakeCase { + <# + .SYNOPSIS + Converts an object with CamelCase properties to a dictionary with snake_case keys. + Works in the spirit of and depends on the existing CamelConversion module util. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [Object] + $InputObject , + + [Parameter()] + [Switch] + $NoRecurse , + + [Parameter()] + [Switch] + $OmitNull + ) + + Process { + $result = [Ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $value = $property.Value + if (-not $NoRecurse -and $value -is [System.Collections.IDictionary]) { + $value = Convert-DictToSnakeCase -dict $value + } + elseif (-not $NoRecurse -and ($value -is [Array] -or $value -is [System.Collections.ArrayList])) { + $value = Convert-ListToSnakeCase -list $value + } + elseif ($null -eq $value) { + if ($OmitNull) { + continue + } + } + elseif (-not $NoRecurse -and $value -isnot [System.ValueType] -and $value -isnot [string]) { + $value = Convert-ObjectToSnakeCase -InputObject $value + } + + $name = Convert-StringToSnakeCase -string $property.Name + $result[$name] = $value + } + $result + } +} + +function ConvertTo-SerializableScriptInfo { + <# + .SYNOPSIS + Transforms some members of a PSRepositoryItemInfo object to be more serialization-friendly. + + .DESCRIPTION + Stringifies [DateTime], [enum], and [type] values for serialization + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Object] + $InputObject , + + [Parameter()] + [string[]] + $ExcludeProperty = @( + <# + Includes is for modules, containing the stuff they export. + #> + 'Includes' , + + <# + This is always 'Script' for scripts. + #> + 'Type' + ) + ) + + Process { + $properties = foreach ($p in $InputObject.PSObject.Properties) { + $pName = $p.Name + $pValue = $p.Value + + if ($pValue -is [datetime]) { + @{ + Name = $pName + Expression = { $pValue.ToString('o') }.GetNewClosure() + } + } + elseif ($pValue -is [enum] -or $pValue -is [type]) { + @{ + Name = $pName + Expression = { $pValue.ToString() }.GetNewClosure() + } + } + else { + $pName + } + } + + $InputObject | Select-Object -Property $properties -ExcludeProperty $ExcludeProperty + } +} + +$module.Result.scripts = @( + Get-InstalledScript -Name $module.Params.name -ErrorAction SilentlyContinue | + Where-Object -FilterScript { -not $module.Params.repository -or $_.Repository -eq $module.Params.repository } | + ConvertTo-SerializableScriptInfo | + Convert-ObjectToSnakeCase -NoRecurse +) + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_psscript_info.py b/ansible_collections/community/windows/plugins/modules/win_psscript_info.py new file mode 100644 index 00000000..7e4c4e33 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_psscript_info.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_psscript_info +short_description: Gather information about installed PowerShell Scripts +description: + - Gather information about PowerShell Scripts installed via PowerShellGet. +options: + name: + description: + - The name of the script. + - Supports any wildcard pattern supported by C(Get-InstalledScript). + - If omitted then all scripts will returned. + type: str + default: '*' + repository: + description: + - The name of the PSRepository the scripts were installed from. + - This acts as a filter against the scripts that would be returned based on the I(name) option. + - Only scripts installed from a registered repository will be returned. + - If the repository was re-registered after script installation with a new C(SourceLocation), this will not match. + type: str +requirements: + - C(PowerShellGet) module +seealso: + - module: community.windows.win_psrepository_info + - module: community.windows.win_psmodule_info +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Get info about all script on the system + community.windows.win_psscript_info: + +- name: Get info about the Test-RPC script + community.windows.win_psscript_info: + name: Test-RPC + +- name: Get info about test scripts + community.windows.win_psscript_info: + name: Test* + +- name: Get info about all scripts installed from the PSGallery repository + community.windows.win_psscript_info: + repository: PSGallery + register: gallery_scripts + +- name: Update all scripts retrieved from above example + community.windows.win_psscript: + name: "{{ item }}" + state: latest + loop: "{{ gallery_scripts.scripts | map(attribute=name) }}" + +- name: Get info about all scripts on the system + community.windows.win_psscript_info: + register: all_scripts + +- name: Find scripts installed from a repository that isn't registered now + set_fact: + missing_repository_scripts: "{{ + all_scripts + | json_query('scripts[?repository!=null && repository==repository_source_location].{name: name, version: version, repository: repository}') + | list + }}" + +- debug: + var: missing_repository_scripts +''' + +RETURN = r''' +scripts: + description: + - A list of installed scripts (or an empty list is there are none). + returned: always + type: list + elements: dict + contains: + name: + description: + - The name of the script. + type: str + sample: Test-RPC + version: + description: + - The script version. + type: str + sample: 1.2.3 + installed_location: + description: + - The path where the script is installed. + type: str + sample: 'C:\Program Files\WindowsPowerShell\Scripts' + author: + description: + - The author of the script. + type: str + sample: Ryan Ries + copyright: + description: + - The copyright of the script. + type: str + sample: 'Jordan Borean 2017' + company_name: + description: + - The company name of the script. + type: str + sample: Microsoft Corporation + description: + description: + - The description of the script. + type: str + sample: This scripts tests network connectivity. + dependencies: + description: + - The script's dependencies. + type: list + elements: str + icon_uri: + description: + - The address of the icon of the script. + type: str + sample: 'https://raw.githubusercontent.com/scripter/script/main/logo.png' + license_uri: + description: + - The address of the license for the script. + type: str + sample: 'https://raw.githubusercontent.com/scripter/script/main/LICENSE' + project_uri: + description: + - The address of the script's project. + type: str + sample: 'https://github.com/scripter/script' + repository_source_location: + description: + - The source location of the repository where the script was installed from. + type: str + sample: 'https://www.powershellgallery.com/api/v2' + repository: + description: + - The PSRepository where the script was installed from. + - This value is not historical. It depends on the PSRepositories that are registered now for the current user. + - The C(repository_source_location) must match the current source location of a registered repository to get a repository name. + - If there is no match, then this value will match C(repository_source_location). + type: str + sample: PSGallery + release_notes: + description: + - The script's release notes. This is a free text field and no specific format should be assumed. + type: str + sample: | + ## 1.5.5 + - Add optional param for detailed info + + ## 1.4.7 + - Bug fix for deadlock when getting parameters in an event + + ## 1.1.4 + - Bug fix when installing package from private feeds + installed_date: + description: + - The date the script was installed. + type: str + sample: '2018-02-14T17:55:34.9620740-05:00' + published_date: + description: + - The date the script was published. + type: str + sample: '2017-03-15T04:18:09.0000000' + updated_date: + description: + - The date the script was last updated. + type: str + sample: '2019-12-31T09:20:02.0000000' + package_management_provider: + description: + - This is the PowerShellGet package management provider used to install the script. + type: str + sample: NuGet + tags: + description: + - The tags defined in the script's C(AdditionalMetadata). + type: list + elements: str + sample: + - networking + - serialization + - git + - dsc + power_shell_get_format_version: + description: + - The version of the PowerShellGet specification format. + type: str + sample: '2.0' + additional_metadata: + description: + - Additional metadata included with the script or during publishing of the script. + - Many of the fields here are surfaced at the top level with some standardization. The values here may differ slightly as a result. + - The field names here vary widely in case, and are not normalized or converted to snake_case. + type: dict +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.ps1 b/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.ps1 new file mode 100644 index 00000000..e4dd6b7b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.ps1 @@ -0,0 +1,555 @@ +#!powershell + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -CSharpUtil Ansible.AccessToken + +$type = @{ + guid = [Func[[Object], [System.Guid]]] { + [System.Guid]::ParseExact($args[0].Trim([char[]]'}{').Replace('-', ''), 'N') + } + version = [Func[[Object], [System.Version]]] { + [System.Version]::Parse($args[0]) + } + int64 = [Func[[Object], [System.Int64]]] { + [System.Int64]::Parse($args[0]) + } + double = [Func[[Object], [System.Double]]] { + [System.Double]::Parse($args[0]) + } +} + +$pssc_options = @{ + guid = @{ type = $type.guid } + schema_version = @{ type = $type.version } + author = @{ type = 'str' } + description = @{ type = 'str' } + company_name = @{ type = 'str' } + copyright = @{ type = 'str' } + session_type = @{ type = 'str' ; choices = @('default', 'empty', 'restricted_remote_server') } + transcript_directory = @{ type = 'path' } + run_as_virtual_account = @{ type = 'bool' } + run_as_virtual_account_groups = @{ type = 'list' ; elements = 'str' } + mount_user_drive = @{ type = 'bool' } + user_drive_maximum_size = @{ type = $type.int64 } + group_managed_service_account = @{ type = 'str' } + scripts_to_process = @{ type = 'list' ; elements = 'str' } + role_definitions = @{ type = 'dict' } + required_groups = @{ type = 'dict' } + language_mode = @{ type = 'str' ; choices = @('no_language', 'restricted_language', 'constrained_language', 'full_language') } + execution_policy = @{ type = 'str' ; choices = @('default', 'remote_signed', 'restricted', 'undefined', 'unrestricted') } + powershell_version = @{ type = $type.version } + modules_to_import = @{ type = 'list' ; elements = 'raw' } + visible_aliases = @{ type = 'list' ; elements = 'str' } + visible_cmdlets = @{ type = 'list' ; elements = 'raw' } + visible_functions = @{ type = 'list' ; elements = 'raw' } + visible_external_commands = @{ type = 'list' ; elements = 'str' } + alias_definitions = @{ type = 'dict' } + function_definitions = @{ type = 'dict' } + variable_definitions = @{ type = 'list' ; elements = 'dict' } + environment_variables = @{ type = 'dict' } + types_to_process = @{ type = 'list' ; elements = 'path' } + formats_to_process = @{ type = 'list' ; elements = 'path' } + assemblies_to_load = @{ type = 'list' ; elements = 'str' } +} + +$session_configuration_options = @{ + name = @{ type = 'str' ; required = $true } + processor_architecure = @{ type = 'str' ; choices = @('amd64', 'x86') } + access_mode = @{ type = 'str' ; choices = @('disabled', 'local', 'remote') } + use_shared_process = @{ type = 'bool' } + thread_apartment_state = @{ type = 'str' ; choices = @('mta', 'sta') } + thread_options = @{ type = 'str' ; choices = @('default', 'reuse_thread', 'use_current_thread', 'use_new_thread') } + startup_script = @{ type = 'path' } + maximum_received_data_size_per_command_mb = @{ type = $type.double } + maximum_received_object_size_mb = @{ type = $type.double } + security_descriptor_sddl = @{ type = 'str' } + run_as_credential_username = @{ type = 'str' } + run_as_credential_password = @{ type = 'str' ; no_log = $true } +} + +$behavior_options = @{ + state = @{ type = 'str' ; choices = @('present', 'absent') ; default = 'present' } + lenient_config_fields = @{ type = 'list' ; elements = 'str' ; default = @('guid', 'author', 'company_name', 'copyright', 'description') } + async_timeout = @{ type = 'int' ; default = 300 } + async_poll = @{ type = 'int' ; default = 1 } + <# + # TODO: possible future enhancement to wait for existing connections to finish + # Existing connections can be found with: + # Get-WSManInstance -ComputerName localhost -ResourceURI shell -Enumerate + + existing_connection_timeout_seconds = @{ type = 'int' ; default = 0 } + existing_connection_timeout_interval_ms = @{ type = 'int' ; default = 500 } + existing_connection_timeout_action = @{ type = 'str' ; choices = @('terminate', 'fail') ; default = 'terminate' } + existing_connection_wait_states = @{ type = 'list' ; elements = 'str' ; default = @('connected') } +#> +} + +$spec = @{ + options = $pssc_options + $session_configuration_options + $behavior_options + required_together = @( + , @('run_as_credential_username', 'run_as_credential_password') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + + +function Import-PowerShellDataFileLegacy { + <# + .SYNOPSIS + A pre-PowerShell 5.0 version of Import-PowerShellDataFile + + .DESCRIPTION + Safely imports a PowerShell Data file in PowerShell versions before 5.0 + when the built-in command was introduced. Non-literal Path support is not included. + #> + [CmdletBinding()] + [OutputType([hashtable])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Required to process PS data file')] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [String] + $LiteralPath + ) + + End { + $astloader = [System.Management.Automation.Language.Parser]::ParseFile($LiteralPath, [ref] $null , [ref] $null) + $ht = $astloader.Find({ param($ast) + $ast -is [System.Management.Automation.Language.HashtableAst] + }, $false) + + if (-not $ht) { + throw "Invalid PowerShell Data File." + } + + # SafeGetValue() is not available before PowerShell 5 anyway, so we'll do the unsafe load and just execute it. + # The only files we're loading are ones we generated from options, or ones that were already attached to existing + # session configurations. + # $ht.SafeGetValue() + Invoke-Expression -Command $ht.Extent.Text + } +} + +if (-not (Get-Command -Name 'Microsoft.PowerShell.Utility\Import-PowerShellDataFile' -ErrorAction SilentlyContinue)) { + New-Alias -Name 'Import-PowerShellDataFile' -Value 'Import-PowerShellDataFileLegacy' +} + +function ConvertFrom-SnakeCase { + [CmdletBinding()] + [OutputType([String])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [String] + $SnakedString + ) + + Process { + [regex]::Replace($SnakedString, '^_?.|_.', { param($m) $m.Value.TrimStart('_').ToUpperInvariant() }) + } +} + +function ConvertFrom-AnsibleOption { + [CmdletBinding()] + [OutputType([System.Collections.IDictionary])] + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $Params , + + [Parameter(Mandatory = $true)] + [hashtable] + $OptionSet + ) + + End { + $ret = @{} + foreach ($option in $OptionSet.GetEnumerator()) { + $raw_name = $option.Name + switch -Wildcard ($raw_name) { + 'run_as_credential_*' { + $raw_name = $raw_name -replace '_[^_]+$' + $name = ConvertFrom-SnakeCase -SnakedString $raw_name + if (-not $ret.Contains($name)) { + $un = $Params["${raw_name}_username"] + if ($un) { + $secpw = ConvertTo-SecureString -String $Params["${raw_name}_password"] -AsPlainText -Force + $value = New-Object -TypeName PSCredential -ArgumentList $un, $secpw + $ret[$name] = $value + } + } + break + } + + default { + $value = $Params[$raw_name] + if ($null -ne $value) { + if ($option.Value.choices) { + # the options that have choices have them listed in snake_case versions of their real values + $value = ConvertFrom-SnakeCase -SnakedString $value + } + $name = ConvertFrom-SnakeCase -SnakedString $raw_name + $ret[$name] = $value + } + } + } + } + + $ret + } +} + +function Write-GeneratedSessionConfiguration { + [CmdletBinding()] + [OutputType([System.IO.FileInfo])] + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $ParameterSet , + + [Parameter()] + [String] + $OutFile + ) + + End { + $file = if ($OutFile) { + $OutFile + } + else { + [System.IO.Path]::GetTempFileName() + } + + $file = $file -replace '(?<!\.pssc)$', '.pssc' + New-PSSessionConfigurationFile -Path $file @ParameterSet + [System.IO.FileInfo]$file + } +} + +function Compare-ConfigFile { + <# + .SYNOPSIS + This function compares the existing config file to the desired + + .DESCRIPTION + We'll load the contents of both the desired and existing config, remove fields that shouldn't be + compared, then generate a new config based on the existing and compare those files. + + This could be done as a direct file compare, without loading the contents as objects. + The primary reasons to do it this slightly more complicated way are: + + - To ignore GUID as a value that matters: if you don't supply it a new one is generated for you, + but PSSessionConfigurations don't use this for anything; it's just metadata. If you supply one, + we want to compare it. If you don't, we shouldn't count the "mismatch" against you though. + + - To normalize the existing file based on the following stuff so we avoid unnecessary changes: + + - A file compare either has to be case insensitive (won't catch changes in values) or case sensitive + (will may force changes on differences that don't matter, like case differences in key values) + + - A file compare will see changes on whitespace and line ending differences; although those could be + accounted for in other ways, this method handles them. + + - A file compare will see changes on other non-impacting syntax style differences like indentation. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [System.IO.FileInfo] + $ConfigFilePath , + + [Parameter(Mandatory = $true)] + [System.IO.FileInfo] + $NewConfigFile , + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $Params , + + [Parameter()] + [String[]] + $UseExistingIfMissing + ) + + Process { + $desired_config = $NewConfigFile.FullName + + $existing_content = Import-PowerShellDataFile -LiteralPath $ConfigFilePath.FullName + $desired_content = Import-PowerShellDataFile -LiteralPath $desired_config + + $regen = $false + foreach ($ignorable_param in $UseExistingIfMissing) { + # here we're checking for the parameters that shouldn't be compared if they are in the existing + # config, but missing in the desired config. To account for this, we copy the value from the + # existing into the desired so that when we regenerate it, it'll match the existing if there + # aren't other changes. + if (-not $Params.Contains($ignorable_param) -and $existing_content.Contains($ignorable_param)) { + $desired_content[$ignorable_param] = $existing_content[$ignorable_param] + $regen = $true + } + } + + # re-write and read the desired config file + if ($regen) { + $NewConfigFile.Delete() + $desired_config = Write-GeneratedSessionConfiguration -ParameterSet $desired_content -OutFile $desired_config + } + + $desired_content = Get-Content -Raw -LiteralPath $desired_config + + # re-write/import the existing one too to get a pristine version + # this will account for unimporant case differences, comments, whitespace, etc. + $pristine_config = Write-GeneratedSessionConfiguration -ParameterSet $existing_content + $existing_content = Get-Content -Raw -LiteralPath $pristine_config + + # with all this de/serializing out of the way we can just do a simple case-sensitive string compare + $desired_content -ceq $existing_content + + Remove-Item -LiteralPath $pristine_config -Force -ErrorAction SilentlyContinue + } +} + +function Compare-SessionOption { + <# + .DESCRIPTION + This function is used for comparing the session options that don't get set in the config file. + This _should_ have been straightforward for anything other than RunAsCredential, except that for + some godforesaken reason a smattering of settings have names that differ from their parameter name. + + This list is defined internally in PowerShell here: + https://git.io/JfUk7 + + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] + $DesiredOptions , + + [Parameter(Mandatory = $true)] + [Object] + $ExistingOptions + ) + + End { + $optnamer = @{ + ThreadApartmentState = 'pssessionthreadapartmentstate' + ThreadOptions = 'pssessionthreadoptions' + MaximumReceivedDataSizePerCommandMb = 'PSMaximumReceivedDataSizePerCommandMB' + MaximumReceivedObjectSizeMb = 'PSMaximumReceivedObjectSizeMB' + } | Add-Member -MemberType ScriptMethod -Name GetValueOrKey -Value { + param($key) + + $val = $this[$key] + if ($null -eq $val) { + return $key + } + else { + return $val + } + } -Force -PassThru + + if ($DesiredOptions.Contains('RunAsCredential')) { + # since we can't retrieve/compare password, a change must always be made if a cred is specified + return $false + } + $smatch = $true + foreach ($opt in $DesiredOptions.GetEnumerator()) { + $smatch = $smatch -and ( + $existing.($optnamer.GetValueOrKey($opt.Name)) -ceq $opt.Value + ) + if (-not $smatch) { + break + } + } + return $smatch + } +} + +<# + For use with possible future enhancement. + Right now the biggest challenges to this are: + - Ansible's connection itself: the number doesn't go to 0 while we're here running + and waiting for it. I thought being async it would disappear but either that's not + the case or it's taking too long to do so. + + - I have not found a reliable way to determine which WinRM connection is the one used for + the Ansible connection. Over psrp we can use Get-PSSession -ComputerName but that won't + work for the winrm connection plugin. + + - Connections seem to take time to disappear. In tests when trying to start time-limited + sessions, like: + icm -computername . -scriptblock { Start-Sleep -Seconds 30 } -AsJob + After the time elapses the connection lingers for a little while after. Should be ok but + does add some challenges to writing tests. + + - Checking for instances of the shell resource looks reliable, but I'm not yet certain + if it captures all WinRM connections, like CIM connections. Still would be better than + nothing. +#> +# function Wait-WinRMConnection { +# <# +# .SYNOPSIS +# Waits for existing WinRM connections to finish + +# .DESCRIPTION +# Finds existing WinRM connections that are in a set of states (configurable), and waits for them +# to disappear, or times out. +# #> +# [CmdletBinding()] +# param( +# [Parameter(Mandatory=$true)] +# [Ansible.Basic.AnsibleModule] +# $Module +# ) + +# End { +# $action = $Module.Params.existing_connection_timeout_action +# $states = $Module.Params.existing_connection_wait_states +# $timeout_ms = [System.Math]::Min(0, $Module.Params.existing_connection_timeout_seconds) * 1000 +# $interval = [System.Math]::Max([System.Math]::Min(100, $Module.Params.existing_connection_timeout_interval_ms), $timeout_ms) + +# # Would only with psrp +# $thiscon = Get-PSSession -ComputerName . | Select-Object -ExpandProperty InstanceId + +# $sw = New-Object -TypeName System.Diagnostics.Stopwatch + +# do { +# $connections = Get-WSManInstance -ComputerName localhost -ResourceURI shell -Enumerate | +# Where-Object -FilterScript { +# $states -contains $_.State -and ( +# -not $thiscon -or +# $thiscon -ne $_.ShellId +# ) +# } + +# $sw.Start() +# Start-Sleep -Milliseconds $interval +# } while ($connections -and $sw.ElapsedMilliseconds -lt $timeout_ms) +# $sw.Stop() + +# if ($connections -and $action -eq 'fail') { +# # somehow $connections.Count sometimes is blank (not 0) but I can't figure out how that's possible +# $Module.FailJson("$($connections.Count) remained after timeout.") +# } +# } +# } + +$PSDefaultParameterValues = @{ + '*-PSSessionConfiguration:Force' = $true + 'ConvertFrom-AnsibleOption:Params' = $module.Params + 'Wait-WinRMConnection:Module' = $module +} + +$opt_pssc = ConvertFrom-AnsibleOption -OptionSet $pssc_options +$opt_session = ConvertFrom-AnsibleOption -OptionSet $session_configuration_options + +$existing = Get-PSSessionConfiguration -Name $opt_session.Name -ErrorAction SilentlyContinue + +try { + if ($opt_pssc.Count) { + # config file options were passed to the module, so generate a config file from those + $desired_config = Write-GeneratedSessionConfiguration -ParameterSet $opt_pssc + } + if ($existing) { + # the endpoint is registered + if ($existing.ConfigFilePath -and (Test-Path -LiteralPath $existing.ConfigFilePath)) { + # the registered endpoint uses a config file + if ($desired_config) { + # a desired config file exists, so compare it to the existing one + $content_match = $existing | + Compare-ConfigFile -NewConfigFile $desired_config -Params $opt_pssc -UseExistingIfMissing ( + $module.Params.lenient_config_fields | ConvertFrom-SnakeCase + ) + } + else { + # existing endpoint has a config file but no config file options were passed, so there is no match + $content_match = $false + } + } + else { + # existing endpoint doesn't use a config file, so it's a match if there are also no config options passed + $content_match = $opt_pssc.Count -eq 0 + } + + $session_match = Compare-SessionOption -DesiredOptions $opt_session -ExistingOptions $existing + } + + $state = $module.Params.state + + $create = $state -eq 'present' -and (-not $existing -or -not $content_match) + $remove = $existing -and ($state -eq 'absent' -or -not $content_match) + $session_change = -not $session_match -and $state -ne 'absent' + + $module.Result.changed = $create -or $remove -or $session_change + + # In this module, we pre-emptively remove the session configuratin if there's any change + # in the config file options, and then re-register later if needed. + # But if the RunAs credential is wrong, the register will fail, and since we already removed + # the existing one, it will be gone. + # + # So let's ensure we can actually use the credential by logging on with TokenUtil, + # that way we can fail before touching the existing config. + if ($opt_session.Contains('RunAsCredential')) { + $cred = $opt_session.RunAsCredential + $username = $cred.Username + $domain = $null + if ($username.Contains('\')) { + $domain, $username = $username.Split('\') + } + try { + $handle = [Ansible.AccessToken.TokenUtil]::LogonUser($username, $domain, $cred.GetNetworkCredential().Password, 'Network', 'Default') + $handle.Dispose() + } + catch { + $module.FailJson("Could not validate RunAs Credential: $($_.Exception.Message)", $_) + } + } + + if (-not $module.CheckMode) { + if ($remove) { + # Wait-WinRMConnection + Unregister-PSSessionConfiguration -Name $opt_session.Name + } + + if ($create) { + if ($desired_config) { + $opt_session.Path = $desired_config + } + # Wait-WinRMConnection + $null = Register-PSSessionConfiguration @opt_session + } + elseif ($session_change) { + $psso = $opt_session + # Wait-WinRMConnection + Set-PSSessionConfiguration @psso + } + } +} +catch [System.Management.Automation.ParameterBindingException] { + $e = $_ + if ($e.Exception.ErrorId -eq 'NamedParameterNotFound') { + $psv = $PSVersionTable.PSVersion.ToString(2) + $param = $e.Exception.ParameterName + $cmd = $e.InvocationInfo.MyCommand.Name + $message = "Parameter '$param' is not available for '$cmd' in PowerShell $psv." + } + else { + $message = "Unknown parameter binding error: $($e.Exception.Message)" + } + + $module.FailJson($message, $e) +} +finally { + if ($desired_config) { + Remove-Item -LiteralPath $desired_config -Force -ErrorAction SilentlyContinue + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.py b/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.py new file mode 100644 index 00000000..d6beb9ab --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_pssession_configuration.py @@ -0,0 +1,390 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Brian Scholer <@briantist> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_pssession_configuration +short_description: Manage PSSession Configurations +description: + - Register, unregister, and modify PSSession Configurations for PowerShell remoting. +options: + name: + description: + - The name of the session configuration to manage. + type: str + required: yes + state: + description: + - The desired state of the configuration. + type: str + choices: + - present + - absent + default: present + guid: + description: + - The GUID (UUID) of the session configuration file. + - This value is metadata, so it only matters if you use it externally. + - If not set, a value will be generated automatically. + - Acceptable GUID formats are flexible. Any string of 32 hexadecimal digits will be accepted, with all hyphens C(-) and opening/closing C({}) ignored. + - See also I(lenient_config_fields). + type: raw + schema_version: + description: + - The schema version of the session configuration file. + - If not set, a value will be generated automatically. + - Must be a valid .Net System.Version string. + type: raw + author: + description: + - The author of the session configuration. + - This value is metadata and does not affect the functionality of the session configuration. + - If not set, a value may be generated automatically. + - See also I(lenient_config_fields). + type: str + description: + description: + - The description of the session configuration. + - This value is metadata and does not affect the functionality of the session configuration. + - See also I(lenient_config_fields). + type: str + company_name: + description: + - The company that authored the session configuration. + - This value is metadata and does not affect the functionality of the session configuration. + - If not set, a value may be generated automatically. + - See also I(lenient_config_fields). + type: str + copyright: + description: + - The copyright statement of the session configuration. + - This value is metadata and does not affect the functionality of the session configuration. + - If not set, a value may be generated automatically. + - See also I(lenient_config_fields). + type: str + session_type: + description: + - Controls what type of session this is. + type: str + choices: + - default + - empty + - restricted_remote_server + transcript_directory: + description: + - Automatic session transcripts will be written to this directory. + type: path + run_as_virtual_account: + description: + - If C(yes) the session runs as a virtual account. + - Do not use I(run_as_credential_username) and I(run_as_credential_password) to specify a virtual account. + type: bool + run_as_virtual_account_groups: + description: + - If I(run_as_virtual_account=yes) this is a list of groups to add the virtual account to. + type: list + elements: str + mount_user_drive: + description: + - If C(yes) the session creates and mounts a user-specific PSDrive for use with file transfers. + type: bool + user_drive_maximum_size: + description: + - The maximum size of the user drive in bytes. + - Must fit into an Int64. + type: raw + group_managed_service_account: + description: + - If the session will run as a group managed service account (gMSA) then this is the name. + - Do not use I(run_as_credential_username) and I(run_as_credential_password) to specify a gMSA. + type: str + scripts_to_process: + description: + - A list of paths to script files ending in C(.ps1) that should be applied to the session. + type: list + elements: str + role_definitions: + description: + - A dict defining the roles for JEA sessions. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/session-configurations#role-definitions). + type: dict + required_groups: + description: + - For JEA sessions, defines conditional access rules about which groups a connecting user must belong to. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/session-configurations#conditional-access-rules). + type: dict + language_mode: + description: + - Determines the language mode of the PowerShell session. + type: str + choices: + - no_language + - restricted_language + - constrained_language + - full_language + execution_policy: + description: + - The execution policy controlling script execution in the PowerShell session. + type: str + choices: + - default + - remote_signed + - restricted + - undefined + - unrestricted + powershell_version: + description: + - The minimum required PowerShell version for this session. + - Must be a valid .Net System.Version string. + type: raw + modules_to_import: + description: + - A list of modules that should be imported into the session. + - Any valid PowerShell module spec can be used here, so simple str names or dicts can be used. + - If a dict is used, no snake_case conversion is done, so the original PowerShell names must be used. + type: list + elements: raw + visible_aliases: + description: + - The aliases that can be used in the session. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/role-capabilities). + type: list + elements: str + visible_cmdlets: + description: + - The cmdlets that can be used in the session. + - The elements can be simple names or complex command specifications. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/role-capabilities). + type: list + elements: raw + visible_functions: + description: + - The functions that can be used in the session. + - The elements can be simple names or complex command specifications. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/role-capabilities). + type: list + elements: raw + visible_external_commands: + description: + - The external commands and scripts that can be used in the session. + - For more information see U(https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/role-capabilities). + type: list + elements: str + alias_definitions: + description: + - A dict that defines aliases for each session. + type: dict + function_definitions: + description: + - A dict that defines functions for each session. + type: dict + variable_definitions: + description: + - A list of dicts where each elements defines a variable for each session. + type: list + elements: dict + environment_variables: + description: + - A dict that defines environment variables for each session. + type: dict + types_to_process: + description: + - Paths to type definition files to process for each session. + type: list + elements: path + formats_to_process: + description: + - Paths to format definition files to process for each session. + type: list + elements: path + assemblies_to_load: + description: + - The assemblies that should be loaded into each session. + type: list + elements: str + processor_architecure: + description: + - The processor architecture of the session (32 bit vs. 64 bit). + type: str + choices: + - amd64 + - x86 + access_mode: + description: + - Controls whether the session configuration allows connection from the C(local) machine only, both local and C(remote), or none (C(disabled)). + type: str + choices: + - disabled + - local + - remote + use_shared_process: + description: + - If C(yes) then the session shares a process for each session. + type: bool + thread_apartment_state: + description: + - The apartment state for the PowerShell session. + type: str + choices: + - mta + - sta + thread_options: + description: + - Sets thread options for the session. + type: str + choices: + - default + - reuse_thread + - use_current_thread + - use_new_thread + startup_script: + description: + - A script that gets run on session startup. + type: path + maximum_received_data_size_per_command_mb: + description: + - Sets the maximum received data size per command in MB. + - Must fit into a double precision floating point value. + type: raw + maximum_received_object_size_mb: + description: + - Sets the maximum object size in MB. + - Must fit into a double precision floating point value. + type: raw + security_descriptor_sddl: + description: + - An SDDL string that controls which users and groups can connect to the session. + - If I(role_definitions) is specified the security descriptor will be set based on that. + - If this option is not specified the default security descriptor will be applied. + type: str + run_as_credential_username: + description: + - Used to set a RunAs account for the session. All commands executed in the session will be run as this user. + - To use a gMSA, see I(group_managed_service_account). + - To use a virtual account, see I(run_as_virtual_account) and I(run_as_virtual_account_groups). + - Status will always be C(changed) when a RunAs credential is set because the password cannot be retrieved for comparison. + type: str + run_as_credential_password: + description: + - The password for I(run_as_credential_username). + type: str + lenient_config_fields: + description: + - Some fields used in the session configuration do not affect its function, and are sometimes auto-generated when not specified. + - To avoid unnecessarily changing the configuration on each run, the values of these options will only be enforced when they are explicitly specified. + type: list + elements: str + default: + - guid + - author + - company_name + - copyright + - description + async_timeout: + description: + - Sets a timeout for how long in seconds to wait for asynchronous module execution and waiting for the connection to recover. + - Replicates the functionality of the C(async) keyword. + - Has no effect in check mode. + type: int + default: 300 + async_poll: + description: + - Sets a delay in seconds between each check of the asynchronous execution status. + - Replicates the functionality of the C(poll) keyword. + - Has no effect in check mode. + - I(async_poll=0) is not supported. + type: int + default: 1 +notes: + - This module will restart the WinRM service on any change. This will terminate all WinRM connections including those by other Ansible runs. + - Internally this module uses C(async) when not in check mode to ensure things go smoothly when restarting the WinRM service. + - The standard C(async) and C(poll) keywords cannot be used; instead use the I(async_timeout) and I(async_poll) options to control asynchronous execution. + - Options that don't list a default value here will use the defaults of C(New-PSSessionConfigurationFile) and C(Register-PSSessionConfiguration). + - If a value can be specified in both a session config file and directly in the session options, this module will prefer the setting be in the config file. +seealso: + - name: C(New-PSSessionConfigurationFile) Reference + description: Details and defaults for options that end up in the session configuration file. + link: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/new-pssessionconfigurationfile + - name: C(Register-PSSessionConfiguration) Reference + description: Details and defaults for options that are not specified in the session config file. + link: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/register-pssessionconfiguration + - name: PowerShell Just Enough Administration (JEA) + description: Refer to the JEA documentation for advanced usage of some options + link: https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/jea/overview + - name: About Session Configurations + description: General information about session configurations. + link: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_session_configurations + - name: About Session Configuration Files + description: General information about session configuration files. + link: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_session_configuration_files +author: + - Brian Scholer (@briantist) +''' + +EXAMPLES = r''' +- name: Register a session configuration that loads modules automatically + community.windows.win_pssession_configuration: + name: WebAdmin + modules_to_import: + - WebAdministration + - IISAdministration + description: This endpoint has IIS modules pre-loaded + +- name: Set up an admin endpoint with a restricted execution policy + community.windows.win_pssession_configuration: + name: GloboCorp.Admin + company_name: Globo Corp + description: Admin Endpoint + execution_policy: restricted + +- name: Create a complex JEA endpoint + community.windows.win_pssession_configuration: + name: RBAC.Endpoint + session_type: restricted_remote_server + run_as_virtual_account: True + transcript_directory: '\\server\share\Transcripts' + language_mode: no_language + execution_policy: restricted + role_definitions: + 'CORP\IT Support': + RoleCapabilities: + - PasswordResetter + - EmployeeOffboarder + 'CORP\Webhosts': + RoleCapabilities: IISAdmin + visible_functions: + - tabexpansion2 + - help + visible_cmdlets: + - Get-Help + - Name: Get-Service + Parameters: + - Name: DependentServices + - Name: RequiredServices + - Name: Name + ValidateSet: + - WinRM + - W3SVC + - WAS + visible_aliases: + - gsv + state: present + +- name: Remove a session configuration + community.windows.win_pssession_configuration: + name: UnusedEndpoint + state: absent + +- name: Set a sessions configuration with tweaked async values + community.windows.win_pssession_configuration: + name: MySession + description: A sample session + async_timeout: 500 + async_poll: 5 +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.ps1 b/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.ps1 new file mode 100644 index 00000000..f4ac711c --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.ps1 @@ -0,0 +1,156 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +function Get-EnabledPlugin($rabbitmq_plugins_cmd) { + $list_plugins_cmd = "$rabbitmq_plugins_cmd list -E -m" + try { + $enabled_plugins = @(Invoke-Expression "& $list_plugins_cmd" | Where-Object { $_ }) + return , $enabled_plugins + } + catch { + Fail-Json -obj $result -message "Can't execute `"$($list_plugins_cmd)`": $($_.Exception.Message)" + } +} + +function Enable-Plugin($rabbitmq_plugins_cmd, $plugin_name) { + $enable_plugin_cmd = "$rabbitmq_plugins_cmd enable $plugin_name" + try { + Invoke-Expression "& $enable_plugin_cmd" + } + catch { + Fail-Json -obj $result -message "Can't execute `"$($enable_plugin_cmd)`": $($_.Exception.Message)" + } +} + +function Disable-Plugin($rabbitmq_plugins_cmd, $plugin_name) { + $enable_plugin_cmd = "$rabbitmq_plugins_cmd disable $plugin_name" + try { + Invoke-Expression "& $enable_plugin_cmd" + } + catch { + Fail-Json -obj $result -message "Can't execute `"$($enable_plugin_cmd)`": $($_.Exception.Message)" + } +} + +function Get-RabbitmqPathFromRegistry { + $reg64Path = "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\RabbitMQ" + $reg32Path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\RabbitMQ" + + if (Test-Path -LiteralPath $reg64Path) { + $regPath = $reg64Path + } + elseif (Test-Path -LiteralPath $reg32Path) { + $regPath = $reg32Path + } + + if ($regPath) { + $path = Split-Path -Parent (Get-ItemProperty -LiteralPath $regPath "UninstallString").UninstallString + $version = (Get-ItemProperty -LiteralPath $regPath "DisplayVersion").DisplayVersion + return "$path\rabbitmq_server-$version" + } +} + +function Get-RabbitmqBinPath($installation_path) { + $result = Join-Path -Path $installation_path -ChildPath 'bin' + if (Test-Path -LiteralPath $result) { + return $result + } + + $result = Join-Path -Path $installation_path -ChildPath 'sbin' + if (Test-Path -LiteralPath $result) { + return $result + } +} + +$ErrorActionPreference = "Stop" + +$result = @{ + changed = $false + enabled = @() + disabled = @() +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$names = Get-AnsibleParam -obj $params -name "names" -type "str" -failifempty $true -aliases "name" +$new_only = Get-AnsibleParam -obj $params -name "new_only" -type "bool" -default $false +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "enabled" -validateset "enabled", "disabled" +$prefix = Get-AnsibleParam -obj $params -name "prefix" -type "str" + +if ($diff_support) { + $result.diff = @{} + $result.diff.prepared = "" +} + +$plugins = $names.Split(",") + +if ($prefix) { + $rabbitmq_bin_path = Get-RabbitmqBinPath -installation_path $prefix + if (-not $rabbitmq_bin_path) { + Fail-Json -obj $result -message "No binary folder in prefix `"$($prefix)`"" + } +} +else { + $rabbitmq_reg_path = Get-RabbitmqPathFromRegistry + if ($rabbitmq_reg_path) { + $rabbitmq_bin_path = Get-RabbitmqBinPath -installation_path $rabbitmq_reg_path + } +} + +if ($rabbitmq_bin_path) { + $rabbitmq_plugins_cmd = "'$(Join-Path -Path $rabbitmq_bin_path -ChildPath "rabbitmq-plugins")'" +} +else { + $rabbitmq_plugins_cmd = "rabbitmq-plugins" +} + +$enabled_plugins = Get-EnabledPlugin -rabbitmq_plugins_cmd $rabbitmq_plugins_cmd + +if ($state -eq "enabled") { + $plugins_to_enable = $plugins | Where-Object { -not ($enabled_plugins -contains $_) } + foreach ($plugin in $plugins_to_enable) { + if (-not $check_mode) { + Enable-Plugin -rabbitmq_plugins_cmd $rabbitmq_plugins_cmd -plugin_name $plugin + } + if ($diff_support) { + $result.diff.prepared += "+[$plugin]`n" + } + $result.enabled += $plugin + $result.changed = $true + } + + if (-not $new_only) { + $plugins_to_disable = $enabled_plugins | Where-Object { -not ($plugins -contains $_) } + foreach ($plugin in $plugins_to_disable) { + if (-not $check_mode) { + Disable-Plugin -rabbitmq_plugins_cmd $rabbitmq_plugins_cmd -plugin_name $plugin + } + if ($diff_support) { + $result.diff.prepared += "-[$plugin]`n" + } + $result.disabled += $plugin + $result.changed = $true + } + } +} +else { + $plugins_to_disable = $enabled_plugins | Where-Object { $plugins -contains $_ } + foreach ($plugin in $plugins_to_disable) { + if (-not $check_mode) { + Disable-Plugin -rabbitmq_plugins_cmd $rabbitmq_plugins_cmd -plugin_name $plugin + } + if ($diff_support) { + $result.diff.prepared += "-[$plugin]`n" + } + $result.disabled += $plugin + $result.changed = $true + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.py b/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.py new file mode 100644 index 00000000..c981c8d9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rabbitmq_plugin.py @@ -0,0 +1,58 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_rabbitmq_plugin +short_description: Manage RabbitMQ plugins +description: + - Manage RabbitMQ plugins. +options: + names: + description: + - Comma-separated list of plugin names. + type: str + required: yes + aliases: [ name ] + new_only: + description: + - Only enable missing plugins. + - Does not disable plugins that are not in the names list. + type: bool + default: no + state: + description: + - Specify if plugins are to be enabled or disabled. + type: str + choices: [ disabled, enabled ] + default: enabled + prefix: + description: + - Specify a custom install prefix to a Rabbit. + type: str +author: + - Artem Zinenko (@ar7z1) +''' + +EXAMPLES = r''' +- name: Enables the rabbitmq_management plugin + community.windows.win_rabbitmq_plugin: + names: rabbitmq_management + state: enabled +''' + +RETURN = r''' +enabled: + description: List of plugins enabled during task run. + returned: always + type: list + sample: ["rabbitmq_management"] +disabled: + description: List of plugins disabled during task run. + returned: always + type: list + sample: ["rabbitmq_management"] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_cap.ps1 b/ansible_collections/community/windows/plugins/modules/win_rds_cap.ps1 new file mode 100644 index 00000000..4aa6516b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_cap.ps1 @@ -0,0 +1,407 @@ +#!powershell + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.SID + +$ErrorActionPreference = "Stop" + +# List of authentication methods as string. Used for parameter validation and conversion to integer flag, so order is important! +$auth_methods_set = @("none", "password", "smartcard", "both") +# List of session timeout actions as string. Used for parameter validation and conversion to integer flag, so order is important! +$session_timeout_actions_set = @("disconnect", "reauth") + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present", "enabled", "disabled" +$auth_method = Get-AnsibleParam -obj $params -name "auth_method" -type "str" -validateset $auth_methods_set +$order = Get-AnsibleParam -obj $params -name "order" -type "int" +$session_timeout = Get-AnsibleParam -obj $params -name "session_timeout" -type "int" +$session_timeout_action_params = @{ + obj = $params + name = "session_timeout_action" + type = "str" + default = "disconnect" + validateset = $session_timeout_actions_set +} +$session_timeout_action = Get-AnsibleParam @session_timeout_action_params +$idle_timeout = Get-AnsibleParam -obj $params -name "idle_timeout" -type "int" +$allow_only_sdrts_servers = Get-AnsibleParam -obj $params -name "allow_only_sdrts_servers" -type "bool" +$user_groups = Get-AnsibleParam -obj $params -name "user_groups" -type "list" +$computer_groups = Get-AnsibleParam -obj $params -name "computer_groups" -type "list" + +# Device redirections +$redirect_clipboard = Get-AnsibleParam -obj $params -name "redirect_clipboard" -type "bool" +$redirect_drives = Get-AnsibleParam -obj $params -name "redirect_drives" -type "bool" +$redirect_printers = Get-AnsibleParam -obj $params -name "redirect_printers" -type "bool" +$redirect_serial = Get-AnsibleParam -obj $params -name "redirect_serial" -type "bool" +$redirect_pnp = Get-AnsibleParam -obj $params -name "redirect_pnp" -type "bool" + + +Function ConvertTo-Sid { + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [string[]] + $InputObject + ) + + process { + foreach ($user in $InputObject) { + # RDS uses the UPN format with the builtin domain but Convert-ToSID tries to look this up as a domain. + # Ensure the input value is in the Netlogon format to ensure BUILTIN is resolved properly + if ($user.EndsWith("@builtin", [System.StringComparison]::OrdinalIgnoreCase)) { + $user = "BUILTIN\$($user.Substring(0, $user.Length - 8))" + } + + Convert-ToSID -account_name $user + } + } +} + + +function Get-CAP([string] $name) { + $cap_path = "RDS:\GatewayServer\CAP\$name" + $cap = @{ + Name = $name + } + + # Fetch CAP properties + Get-ChildItem -LiteralPath $cap_path | ForEach-Object { $cap.Add($_.Name, $_.CurrentValue) } + # Convert boolean values + $cap.Enabled = $cap.Status -eq 1 + $cap.Remove("Status") + $cap.AllowOnlySDRTSServers = $cap.AllowOnlySDRTSServers -eq 1 + + # Convert multiple choices values + $cap.AuthMethod = $auth_methods_set[$cap.AuthMethod] + $cap.SessionTimeoutAction = $session_timeout_actions_set[$cap.SessionTimeoutAction] + + # Fetch CAP device redirection settings + $cap.DeviceRedirection = @{} + Get-ChildItem -LiteralPath "$cap_path\DeviceRedirection" | ForEach-Object { $cap.DeviceRedirection.Add($_.Name, ($_.CurrentValue -eq 1)) } + + # Fetch CAP user and computer groups in Down-Level Logon format + $cap.UserGroups = @( + Get-ChildItem -LiteralPath "$cap_path\UserGroups" | + Select-Object -ExpandProperty Name | + ForEach-Object { Convert-FromSID -sid (ConvertTo-Sid -InputObject $_) } + ) + $cap.ComputerGroups = @( + Get-ChildItem -LiteralPath "$cap_path\ComputerGroups" | + Select-Object -ExpandProperty Name | + ForEach-Object { Convert-FromSID -sid (ConvertTo-Sid -InputObject $_) } + ) + + return $cap +} + +function Set-CAPPropertyValue { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true)] + [string] $name, + [Parameter(Mandatory = $true)] + [string] $property, + [Parameter(Mandatory = $true)] + $value, + [Parameter()] + $resultobj = @{} + ) + + $cap_path = "RDS:\GatewayServer\CAP\$name" + + try { + Set-Item -LiteralPath "$cap_path\$property" -Value $value -ErrorAction Stop + } + catch { + Fail-Json -obj $resultobj -message "Failed to set property $property of CAP ${name}: $($_.Exception.Message)" + } +} + +$result = @{ + changed = $false +} +$diff_text = $null + +# Validate CAP name +if ($name -match "[*/\\;:?`"<>|\t]+") { + Fail-Json -obj $result -message "Invalid character in CAP name." +} + +# Validate user groups +if ($null -ne $user_groups) { + if ($user_groups.Count -lt 1) { + Fail-Json -obj $result -message "Parameter 'user_groups' cannot be an empty list." + } + + $user_groups = $user_groups | ForEach-Object { + $group = $_ + # Test that the group is resolvable on the local machine + $sid = ConvertTo-Sid -InputObject $group + if (!$sid) { + Fail-Json -obj $result -message "$group is not a valid user group on the host machine or domain" + } + + # Return the normalized group name in Down-Level Logon format + Convert-FromSID -sid $sid + } + $user_groups = @($user_groups) +} + +# Validate computer groups +if ($null -ne $computer_groups) { + $computer_groups = $computer_groups | ForEach-Object { + $group = $_ + # Test that the group is resolvable on the local machine + $sid = ConvertTo-Sid -InputObject $group + if (!$sid) { + Fail-Json -obj $result -message "$group is not a valid computer group on the host machine or domain" + } + + # Return the normalized group name in Down-Level Logon format + Convert-FromSID -sid $sid + } + $computer_groups = @($computer_groups) +} + +# Validate order parameter +if ($null -ne $order -and $order -lt 1) { + Fail-Json -obj $result -message "Parameter 'order' must be a strictly positive integer." +} + +# Ensure RemoteDesktopServices module is loaded +if ($null -eq (Get-Module -Name RemoteDesktopServices -ErrorAction SilentlyContinue)) { + Import-Module -Name RemoteDesktopServices +} + +# Check if a CAP with the given name already exists +$cap_exist = Test-Path -LiteralPath "RDS:\GatewayServer\CAP\$name" + +if ($state -eq 'absent') { + if ($cap_exist) { + Remove-Item -LiteralPath "RDS:\GatewayServer\CAP\$name" -Recurse -WhatIf:$check_mode + $diff_text += "-[$name]" + $result.changed = $true + } +} +else { + $diff_text_added_prefix = '' + if (-not $cap_exist) { + if ($null -eq $user_groups) { + Fail-Json -obj $result -message "User groups must be defined to create a new CAP." + } + + # Auth method is required when creating a new CAP. Set it to password by default. + if ($null -eq $auth_method) { + $auth_method = "password" + } + + # Create a new CAP + if (-not $check_mode) { + $CapArgs = @{ + Name = $name + UserGroupNames = $user_groups -join ';' + } + $cimParams = @{ + Namespace = "Root\CIMV2\TerminalServices" + ClassName = "Win32_TSGatewayConnectionAuthorizationPolicy" + MethodName = "Create" + Arguments = $CapArgs + } + $return = Invoke-CimMethod @cimParams + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to create CAP $name (code: $($return.ReturnValue))" + } + } + + $cap_exist = -not $check_mode + + $diff_text_added_prefix = '+' + $result.changed = $true + } + + $diff_text += "$diff_text_added_prefix[$name]`n" + + # We cannot configure a CAP that was created above in check mode as it won't actually exist + if ($cap_exist) { + $cap = Get-CAP -Name $name + $wmi_cap = Get-CimInstance -ClassName Win32_TSGatewayConnectionAuthorizationPolicy -Namespace Root\CIMv2\TerminalServices -Filter "name='$($name)'" + + if ($state -in @('disabled', 'enabled')) { + $cap_enabled = $state -ne 'disabled' + if ($cap.Enabled -ne $cap_enabled) { + $diff_text += "-State = $(@('disabled', 'enabled')[[int]$cap.Enabled])`n+State = $state`n" + Set-CAPPropertyValue -Name $name -Property Status -Value ([int]$cap_enabled) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + } + + if ($null -ne $auth_method -and $auth_method -ne $cap.AuthMethod) { + $diff_text += "-AuthMethod = $($cap.AuthMethod)`n+AuthMethod = $auth_method`n" + $set_params = @{ + Name = $name + Property = "AuthMethod" + Value = ([array]::IndexOf($auth_methods_set, $auth_method)) + ResultObj = $result + WhatIf = $check_mode + } + Set-CAPPropertyValue @set_params + $result.changed = $true + } + + if ($null -ne $order -and $order -ne $cap.EvaluationOrder) { + # Order cannot be greater than the total number of existing CAPs (InvalidArgument exception) + $cap_count = (Get-ChildItem -LiteralPath "RDS:\GatewayServer\CAP").Count + if ($order -gt $cap_count) { + $msg = -join @( + "Given value '$order' for parameter 'order' is greater than the number of existing CAPs. " + "The actual order will be capped to '$cap_count'." + ) + Add-Warning -obj $result -message $msg + $order = $cap_count + } + + $diff_text += "-Order = $($cap.EvaluationOrder)`n+Order = $order`n" + Set-CAPPropertyValue -Name $name -Property EvaluationOrder -Value $order -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $session_timeout -and ($session_timeout -ne $cap.SessionTimeout -or $session_timeout_action -ne $cap.SessionTimeoutAction)) { + try { + Set-Item -Path "RDS:\GatewayServer\CAP\$name\SessionTimeout" ` + -Value $session_timeout ` + -SessionTimeoutAction ([array]::IndexOf($session_timeout_actions_set, $session_timeout_action)) ` + -ErrorAction Stop ` + -WhatIf:$check_mode + } + catch { + Fail-Json -obj $result -message "Failed to set property ComputerGroupType of RAP ${name}: $($_.Exception.Message)" + } + + $diff_text += "-SessionTimeoutAction = $($cap.SessionTimeoutAction)`n+SessionTimeoutAction = $session_timeout_action`n" + $diff_text += "-SessionTimeout = $($cap.SessionTimeout)`n+SessionTimeout = $session_timeout`n" + $result.changed = $true + } + + if ($null -ne $idle_timeout -and $idle_timeout -ne $cap.IdleTimeout) { + $diff_text += "-IdleTimeout = $($cap.IdleTimeout)`n+IdleTimeout = $idle_timeout`n" + Set-CAPPropertyValue -Name $name -Property IdleTimeout -Value $idle_timeout -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $allow_only_sdrts_servers -and $allow_only_sdrts_servers -ne $cap.AllowOnlySDRTSServers) { + $diff_text += "-AllowOnlySDRTSServers = $($cap.AllowOnlySDRTSServers)`n+AllowOnlySDRTSServers = $allow_only_sdrts_servers`n" + Set-CAPPropertyValue -Name $name -Property AllowOnlySDRTSServers -Value ([int]$allow_only_sdrts_servers) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $redirect_clipboard -and $redirect_clipboard -ne $cap.DeviceRedirection.Clipboard) { + $diff_text += "-RedirectClipboard = $($cap.DeviceRedirection.Clipboard)`n+RedirectClipboard = $redirect_clipboard`n" + Set-CAPPropertyValue -Name $name -Property "DeviceRedirection\Clipboard" -Value ([int]$redirect_clipboard) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $redirect_drives -and $redirect_drives -ne $cap.DeviceRedirection.DiskDrives) { + $diff_text += "-RedirectDrives = $($cap.DeviceRedirection.DiskDrives)`n+RedirectDrives = $redirect_drives`n" + Set-CAPPropertyValue -Name $name -Property "DeviceRedirection\DiskDrives" -Value ([int]$redirect_drives) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $redirect_printers -and $redirect_printers -ne $cap.DeviceRedirection.Printers) { + $diff_text += "-RedirectPrinters = $($cap.DeviceRedirection.Printers)`n+RedirectPrinters = $redirect_printers`n" + Set-CAPPropertyValue -Name $name -Property "DeviceRedirection\Printers" -Value ([int]$redirect_printers) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $redirect_serial -and $redirect_serial -ne $cap.DeviceRedirection.SerialPorts) { + $diff_text += "-RedirectSerial = $($cap.DeviceRedirection.SerialPorts)`n+RedirectSerial = $redirect_serial`n" + Set-CAPPropertyValue -Name $name -Property "DeviceRedirection\SerialPorts" -Value ([int]$redirect_serial) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $redirect_pnp -and $redirect_pnp -ne $cap.DeviceRedirection.PlugAndPlayDevices) { + $diff_text += "-RedirectPnP = $($cap.DeviceRedirection.PlugAndPlayDevices)`n+RedirectPnP = $redirect_pnp`n" + Set-CAPPropertyValue -Name $name -Property "DeviceRedirection\PlugAndPlayDevices" -Value ([int]$redirect_pnp) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $user_groups) { + $groups_to_remove = @($cap.UserGroups | Where-Object { $user_groups -notcontains $_ }) + $groups_to_add = @($user_groups | Where-Object { $cap.UserGroups -notcontains $_ }) + + $user_groups_diff = $null + foreach ($group in $groups_to_add) { + if (-not $check_mode) { + $return = $wmi_cap | Invoke-CimMethod -MethodName AddUserGroupNames -Arguments @{ UserGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to add user group $($group) (code: $($return.ReturnValue))" + } + } + $user_groups_diff += " +$group`n" + $result.changed = $true + } + + foreach ($group in $groups_to_remove) { + if (-not $check_mode) { + $return = $wmi_cap | Invoke-CimMethod -MethodName RemoveUserGroupNames -Arguments @{ UserGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to remove user group $($group) (code: $($return.ReturnValue))" + } + } + $user_groups_diff += " -$group`n" + $result.changed = $true + } + + if ($user_groups_diff) { + $diff_text += "~UserGroups`n$user_groups_diff" + } + } + + if ($null -ne $computer_groups) { + $groups_to_remove = @($cap.ComputerGroups | Where-Object { $computer_groups -notcontains $_ }) + $groups_to_add = @($computer_groups | Where-Object { $cap.ComputerGroups -notcontains $_ }) + + $computer_groups_diff = $null + foreach ($group in $groups_to_add) { + if (-not $check_mode) { + $return = $wmi_cap | Invoke-CimMethod -MethodName AddComputerGroupNames -Arguments @{ ComputerGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to add computer group $($group) (code: $($return.ReturnValue))" + } + } + $computer_groups_diff += " +$group`n" + $result.changed = $true + } + + foreach ($group in $groups_to_remove) { + if (-not $check_mode) { + $return = $wmi_cap | Invoke-CimMethod -MethodName RemoveComputerGroupNames -Arguments @{ ComputerGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to remove computer group $($group) (code: $($return.ReturnValue))" + } + } + $computer_groups_diff += " -$group`n" + $result.changed = $true + } + + if ($computer_groups_diff) { + $diff_text += "~ComputerGroups`n$computer_groups_diff" + } + } + } +} + +if ($diff_mode -and $result.changed -eq $true) { + $result.diff = @{ + prepared = $diff_text + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_cap.py b/ansible_collections/community/windows/plugins/modules/win_rds_cap.py new file mode 100644 index 00000000..2513b047 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_cap.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_rds_cap +short_description: Manage Connection Authorization Policies (CAP) on a Remote Desktop Gateway server +description: + - Creates, removes and configures a Remote Desktop connection authorization policy (RD CAP). + - A RD CAP allows you to specify the users who can connect to a Remote Desktop Gateway server. +author: + - Kevin Subileau (@ksubileau) +options: + name: + description: + - Name of the connection authorization policy. + type: str + required: yes + state: + description: + - The state of connection authorization policy. + - If C(absent) will ensure the policy is removed. + - If C(present) will ensure the policy is configured and exists. + - If C(enabled) will ensure the policy is configured, exists and enabled. + - If C(disabled) will ensure the policy is configured, exists, but disabled. + type: str + choices: [ absent, enabled, disabled, present ] + default: present + auth_method: + description: + - Specifies how the RD Gateway server authenticates users. + - When a new CAP is created, the default value is C(password). + type: str + choices: [ both, none, password, smartcard ] + order: + description: + - Evaluation order of the policy. + - The CAP in which I(order) is set to a value of '1' is evaluated first. + - By default, a newly created CAP will take the first position. + - If the given value exceed the total number of existing policies, + the policy will take the last position but the evaluation order + will be capped to this number. + type: int + session_timeout: + description: + - The maximum time, in minutes, that a session can be idle. + - A value of zero disables session timeout. + type: int + session_timeout_action: + description: + - The action the server takes when a session times out. + - 'C(disconnect): disconnect the session.' + - 'C(reauth): silently reauthenticate and reauthorize the session.' + type: str + choices: [ disconnect, reauth ] + default: disconnect + idle_timeout: + description: + - Specifies the time interval, in minutes, after which an idle session is disconnected. + - A value of zero disables idle timeout. + type: int + allow_only_sdrts_servers: + description: + - Specifies whether connections are allowed only to Remote Desktop Session Host servers that + enforce Remote Desktop Gateway redirection policy. + type: bool + user_groups: + description: + - A list of user groups that is allowed to connect to the Remote Gateway server. + - Required when a new CAP is created. + type: list + elements: str + computer_groups: + description: + - A list of computer groups that is allowed to connect to the Remote Gateway server. + type: list + elements: str + redirect_clipboard: + description: + - Allow clipboard redirection. + type: bool + redirect_drives: + description: + - Allow disk drive redirection. + type: bool + redirect_printers: + description: + - Allow printers redirection. + type: bool + redirect_serial: + description: + - Allow serial port redirection. + type: bool + redirect_pnp: + description: + - Allow Plug and Play devices redirection. + type: bool +requirements: + - Windows Server 2008R2 (6.1) or higher. + - The Windows Feature "RDS-Gateway" must be enabled. +seealso: +- module: community.windows.win_rds_cap +- module: community.windows.win_rds_rap +- module: community.windows.win_rds_settings +''' + +EXAMPLES = r''' +- name: Create a new RDS CAP with a 30 minutes timeout and clipboard redirection enabled + community.windows.win_rds_cap: + name: My CAP + user_groups: + - BUILTIN\users + session_timeout: 30 + session_timeout_action: disconnect + allow_only_sdrts_servers: yes + redirect_clipboard: yes + redirect_drives: no + redirect_printers: no + redirect_serial: no + redirect_pnp: no + state: enabled +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_rap.ps1 b/ansible_collections/community/windows/plugins/modules/win_rds_rap.ps1 new file mode 100644 index 00000000..956a812d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_rap.ps1 @@ -0,0 +1,318 @@ +#!powershell + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.SID + +$ErrorActionPreference = "Stop" + +# List of authentication methods as string. Used for parameter validation and conversion to integer flag, so order is important! +$computer_group_types = @("rdg_group", "ad_network_resource_group", "allow_any") +$computer_group_types_wmi = @{rdg_group = "RG"; ad_network_resource_group = "CG"; allow_any = "ALL" } + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$description = Get-AnsibleParam -obj $params -name "description" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present", "enabled", "disabled" +$computer_group_type = Get-AnsibleParam -obj $params -name "computer_group_type" -type "str" -validateset $computer_group_types +$computer_group_failure = ($computer_group_type -eq "ad_network_resource_group" -or $computer_group_type -eq "rdg_group") +$computer_group = Get-AnsibleParam -obj $params -name "computer_group" -type "str" -failifempty $computer_group_failure +$user_groups = Get-AnsibleParam -obj $params -name "user_groups" -type "list" +$allowed_ports = Get-AnsibleParam -obj $params -name "allowed_ports" -type "list" + + +Function ConvertTo-Sid { + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [string[]] + $InputObject + ) + + process { + foreach ($user in $InputObject) { + # RDS uses the UPN format with the builtin domain but Convert-ToSID tries to look this up as a domain. + # Ensure the input value is in the Netlogon format to ensure BUILTIN is resolved properly + if ($user.EndsWith("@builtin", [System.StringComparison]::OrdinalIgnoreCase)) { + $user = "BUILTIN\$($user.Substring(0, $user.Length - 8))" + } + + Convert-ToSID -account_name $user + } + } +} + + +function Get-RAP([string] $name) { + $rap_path = "RDS:\GatewayServer\RAP\$name" + $rap = @{ + Name = $name + } + + # Fetch RAP properties + Get-ChildItem -LiteralPath $rap_path | ForEach-Object { $rap.Add($_.Name, $_.CurrentValue) } + # Convert boolean values + $rap.Enabled = $rap.Status -eq 1 + $rap.Remove("Status") + + # Convert computer group name from UPN to Down-Level Logon format + if ($rap.ComputerGroupType -ne 2) { + $rap.ComputerGroup = Convert-FromSID -sid (ConvertTo-SID -InputObject $rap.ComputerGroup) + } + + # Convert multiple choices values + $rap.ComputerGroupType = $computer_group_types[$rap.ComputerGroupType] + + # Convert allowed ports from string to list + if ($rap.PortNumbers -eq '*') { + $rap.PortNumbers = @("any") + } + else { + $rap.PortNumbers = @($rap.PortNumbers -split ',') + } + + # Fetch RAP user groups in Down-Level Logon format + $rap.UserGroups = @( + Get-ChildItem -LiteralPath "$rap_path\UserGroups" | + Select-Object -ExpandProperty Name | + ForEach-Object { Convert-FromSID -sid (ConvertTo-Sid -InputObject $_) } + ) + + return $rap +} + +function Set-RAPPropertyValue { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true)] + [string] $name, + [Parameter(Mandatory = $true)] + [string] $property, + [Parameter(Mandatory = $true)] + $value, + [Parameter()] + $resultobj = @{} + ) + + $rap_path = "RDS:\GatewayServer\RAP\$name" + + try { + Set-Item -LiteralPath "$rap_path\$property" -Value $value -ErrorAction stop + } + catch { + Fail-Json -obj $resultobj -message "Failed to set property $property of RAP ${name}: $($_.Exception.Message)" + } +} + +$result = @{ + changed = $false +} +$diff_text = $null + +# Validate RAP name +if ($name -match "[*/\\;:?`"<>|\t]+") { + Fail-Json -obj $result -message "Invalid character in RAP name." +} + +# Validate user groups +if ($null -ne $user_groups) { + if ($user_groups.Count -lt 1) { + Fail-Json -obj $result -message "Parameter 'user_groups' cannot be an empty list." + } + + $user_groups = $user_groups | ForEach-Object { + $group = $_ + # Test that the group is resolvable on the local machine + $sid = ConvertTo-Sid -InputObject $group + if (!$sid) { + Fail-Json -obj $result -message "$group is not a valid user group on the host machine or domain." + } + + # Return the normalized group name in Down-Level Logon format + Convert-FromSID -sid $sid + } + $user_groups = @($user_groups) +} + +# Validate computer group parameter +if ($computer_group_type -eq "allow_any" -and $null -ne $computer_group) { + Add-Warning -obj $result -message "Parameter 'computer_group' ignored because the computer_group_type is set to allow_any." +} +elseif ($computer_group_type -eq "rdg_group" -and -not (Test-Path -LiteralPath "RDS:\GatewayServer\GatewayManagedComputerGroups\$computer_group")) { + Fail-Json -obj $result -message "$computer_group is not a valid gateway managed computer group" +} +elseif ($computer_group_type -eq "ad_network_resource_group") { + $sid = ConvertTo-Sid -InputObject $computer_group + if (!$sid) { + Fail-Json -obj $result -message "$computer_group is not a valid computer group on the host machine or domain." + } + # Ensure the group name is in Down-Level Logon format + $computer_group = Convert-FromSID -sid $sid +} + +# Validate port numbers +if ($null -ne $allowed_ports) { + foreach ($port in $allowed_ports) { + if (-not ($port -eq "any" -or ($port -is [int] -and $port -ge 1 -and $port -le 65535))) { + Fail-Json -obj $result -message "$port is not a valid port number." + } + } +} + +# Ensure RemoteDesktopServices module is loaded +if ($null -eq (Get-Module -Name RemoteDesktopServices -ErrorAction SilentlyContinue)) { + Import-Module -Name RemoteDesktopServices +} + +# Check if a RAP with the given name already exists +$rap_exist = Test-Path -LiteralPath "RDS:\GatewayServer\RAP\$name" + +if ($state -eq 'absent') { + if ($rap_exist) { + Remove-Item -LiteralPath "RDS:\GatewayServer\RAP\$name" -Recurse -WhatIf:$check_mode + $diff_text += "-[$name]" + $result.changed = $true + } +} +else { + $diff_text_added_prefix = '' + if (-not $rap_exist) { + if ($null -eq $user_groups) { + Fail-Json -obj $result -message "User groups must be defined to create a new RAP." + } + + # Computer group type is required when creating a new RAP. Set it to allow connect to any resource by default. + if ($null -eq $computer_group_type) { + $computer_group_type = "allow_any" + } + + # Create a new RAP + if (-not $check_mode) { + $RapArgs = @{ + Name = $name + ResourceGroupType = 'ALL' + UserGroupNames = $user_groups -join ';' + ProtocolNames = 'RDP' + PortNumbers = '*' + } + $cimParams = @{ + Namespace = "Root\CIMV2\TerminalServices" + ClassName = "Win32_TSGatewayResourceAuthorizationPolicy" + MethodName = "Create" + Arguments = $RapArgs + } + $return = Invoke-CimMethod @cimParams + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to create RAP $name (code: $($return.ReturnValue))" + } + } + $rap_exist = -not $check_mode + + $diff_text_added_prefix = '+' + $result.changed = $true + } + + $diff_text += "$diff_text_added_prefix[$name]`n" + + # We cannot configure a RAP that was created above in check mode as it won't actually exist + if ($rap_exist) { + $rap = Get-RAP -Name $name + $wmi_rap = Get-CimInstance -ClassName Win32_TSGatewayResourceAuthorizationPolicy -Namespace Root\CIMv2\TerminalServices -Filter "name='$($name)'" + + if ($state -in @('disabled', 'enabled')) { + $rap_enabled = $state -ne 'disabled' + if ($rap.Enabled -ne $rap_enabled) { + $diff_text += "-State = $(@('disabled', 'enabled')[[int]$rap.Enabled])`n+State = $state`n" + Set-RAPPropertyValue -Name $name -Property Status -Value ([int]$rap_enabled) -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + } + + if ($null -ne $description -and $description -ne $rap.Description) { + Set-RAPPropertyValue -Name $name -Property Description -Value $description -ResultObj $result -WhatIf:$check_mode + $diff_text += "-Description = $($rap.Description)`n+Description = $description`n" + $result.changed = $true + } + + if ($null -ne $allowed_ports -and @(Compare-Object $rap.PortNumbers $allowed_ports -SyncWindow 0).Count -ne 0) { + $diff_text += "-AllowedPorts = [$($rap.PortNumbers -join ',')]`n+AllowedPorts = [$($allowed_ports -join ',')]`n" + if ($allowed_ports -contains 'any') { $allowed_ports = '*' } + Set-RAPPropertyValue -Name $name -Property PortNumbers -Value $allowed_ports -ResultObj $result -WhatIf:$check_mode + $result.changed = $true + } + + if ($null -ne $computer_group_type -and $computer_group_type -ne $rap.ComputerGroupType) { + $diff_text += "-ComputerGroupType = $($rap.ComputerGroupType)`n+ComputerGroupType = $computer_group_type`n" + if ($computer_group_type -ne "allow_any") { + $diff_text += "+ComputerGroup = $computer_group`n" + } + $return = $wmi_rap | Invoke-CimMethod -MethodName SetResourceGroup -Arguments @{ + ResourceGroupName = $computer_group + ResourceGroupType = $computer_group_types_wmi.$($computer_group_type) + } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to set computer group type to $($computer_group_type) (code: $($return.ReturnValue))" + } + + $result.changed = $true + + } + elseif ($null -ne $computer_group -and $computer_group -ne $rap.ComputerGroup) { + $diff_text += "-ComputerGroup = $($rap.ComputerGroup)`n+ComputerGroup = $computer_group`n" + $return = $wmi_rap | Invoke-CimMethod -MethodName SetResourceGroup -Arguments @{ + ResourceGroupName = $computer_group + ResourceGroupType = $computer_group_types_wmi.$($rap.ComputerGroupType) + } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to set computer group name to $($computer_group) (code: $($return.ReturnValue))" + } + $result.changed = $true + } + + if ($null -ne $user_groups) { + $groups_to_remove = @($rap.UserGroups | Where-Object { $user_groups -notcontains $_ }) + $groups_to_add = @($user_groups | Where-Object { $rap.UserGroups -notcontains $_ }) + + $user_groups_diff = $null + foreach ($group in $groups_to_add) { + if (-not $check_mode) { + $return = $wmi_rap | Invoke-CimMethod -MethodName AddUserGroupNames -Arguments @{ UserGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to add user group $($group) (code: $($return.ReturnValue))" + } + } + $user_groups_diff += " +$group`n" + $result.changed = $true + } + + foreach ($group in $groups_to_remove) { + if (-not $check_mode) { + $return = $wmi_rap | Invoke-CimMethod -MethodName RemoveUserGroupNames -Arguments @{ UserGroupNames = $group } + if ($return.ReturnValue -ne 0) { + Fail-Json -obj $result -message "Failed to remove user group $($group) (code: $($return.ReturnValue))" + } + } + $user_groups_diff += " -$group`n" + $result.changed = $true + } + + if ($user_groups_diff) { + $diff_text += "~UserGroups`n$user_groups_diff" + } + } + } +} + +if ($diff_mode -and $result.changed -eq $true) { + $result.diff = @{ + prepared = $diff_text + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_rap.py b/ansible_collections/community/windows/plugins/modules/win_rds_rap.py new file mode 100644 index 00000000..24ca0f77 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_rap.py @@ -0,0 +1,86 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_rds_rap +short_description: Manage Resource Authorization Policies (RAP) on a Remote Desktop Gateway server +description: + - Creates, removes and configures a Remote Desktop resource authorization policy (RD RAP). + - A RD RAP allows you to specify the network resources (computers) that users can connect + to remotely through a Remote Desktop Gateway server. +author: + - Kevin Subileau (@ksubileau) +options: + name: + description: + - Name of the resource authorization policy. + required: yes + state: + description: + - The state of resource authorization policy. + - If C(absent) will ensure the policy is removed. + - If C(present) will ensure the policy is configured and exists. + - If C(enabled) will ensure the policy is configured, exists and enabled. + - If C(disabled) will ensure the policy is configured, exists, but disabled. + type: str + choices: [ absent, disabled, enabled, present ] + default: present + description: + description: + - Optional description of the resource authorization policy. + type: str + user_groups: + description: + - List of user groups that are associated with this resource authorization policy (RAP). + A user must belong to one of these groups to access the RD Gateway server. + - Required when a new RAP is created. + type: list + elements: str + allowed_ports: + description: + - List of port numbers through which connections are allowed for this policy. + - To allow connections through any port, specify 'any'. + type: list + elements: str + computer_group_type: + description: + - 'The computer group type:' + - 'C(rdg_group): RD Gateway-managed group' + - 'C(ad_network_resource_group): Active Directory Domain Services network resource group' + - 'C(allow_any): Allow users to connect to any network resource.' + type: str + choices: [ rdg_group, ad_network_resource_group, allow_any ] + computer_group: + description: + - The computer group name that is associated with this resource authorization policy (RAP). + - This is required when I(computer_group_type) is C(rdg_group) or C(ad_network_resource_group). + type: str +requirements: + - Windows Server 2008R2 (6.1) or higher. + - The Windows Feature "RDS-Gateway" must be enabled. +seealso: +- module: community.windows.win_rds_cap +- module: community.windows.win_rds_rap +- module: community.windows.win_rds_settings +''' + +EXAMPLES = r''' +- name: Create a new RDS RAP + community.windows.win_rds_rap: + name: My RAP + description: Allow all users to connect to any resource through ports 3389 and 3390 + user_groups: + - BUILTIN\users + computer_group_type: allow_any + allowed_ports: + - 3389 + - 3390 + state: enabled +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_settings.ps1 b/ansible_collections/community/windows/plugins/modules/win_rds_settings.ps1 new file mode 100644 index 00000000..7927f333 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_settings.ps1 @@ -0,0 +1,95 @@ +#!powershell + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +# List of ssl bridging methods as string. Used for parameter validation and conversion to integer flag, so order is important! +$ssl_bridging_methods = @("none", "https_http", "https_https") + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$certificate = Get-AnsibleParam $params -name "certificate_hash" -type "str" +$max_connections = Get-AnsibleParam $params -name "max_connections" -type "int" +$ssl_bridging = Get-AnsibleParam -obj $params -name "ssl_bridging" -type "str" -validateset $ssl_bridging_methods +$enable_only_messaging_capable_clients = Get-AnsibleParam $params -name "enable_only_messaging_capable_clients" -type "bool" + +$result = @{ + changed = $false +} +$diff_text = $null + +# Ensure RemoteDesktopServices module is loaded +if ($null -eq (Get-Module -Name RemoteDesktopServices -ErrorAction SilentlyContinue)) { + Import-Module -Name RemoteDesktopServices +} + +if ($null -ne $certificate) { + # Validate cert path + $cert_path = "cert:\LocalMachine\My\$certificate" + If (-not (Test-Path -LiteralPath $cert_path) ) { + Fail-Json -obj $result -message "Unable to locate certificate at $cert_path" + } + + # Get current certificate hash + $current_cert = (Get-Item -LiteralPath "RDS:\GatewayServer\SSLCertificate\Thumbprint").CurrentValue + if ($current_cert -ne $certificate) { + Set-Item -LiteralPath "RDS:\GatewayServer\SSLCertificate\Thumbprint" -Value $certificate -WhatIf:$check_mode + $diff_text += "-Certificate = $current_cert`n+Certificate = $certificate`n" + $result.changed = $true + } +} + +if ($null -ne $max_connections) { + # Set the correct value for unlimited connections + # TODO Use a more explicit value, maybe a string (ex: "max", "none" or "unlimited") ? + If ($max_connections -eq -1) { + $max_connections = (Get-Item -LiteralPath "RDS:\GatewayServer\MaxConnectionsAllowed").CurrentValue + } + + # Get current connections limit + $current_max_connections = (Get-Item -LiteralPath "RDS:\GatewayServer\MaxConnections").CurrentValue + if ($current_max_connections -ne $max_connections) { + Set-Item -LiteralPath "RDS:\GatewayServer\MaxConnections" -Value $max_connections -WhatIf:$check_mode + $diff_text += "-MaxConnections = $current_max_connections`n+MaxConnections = $max_connections`n" + $result.changed = $true + } +} + +if ($null -ne $ssl_bridging) { + $current_ssl_bridging = (Get-Item -LiteralPath "RDS:\GatewayServer\SSLBridging").CurrentValue + # Convert the integer value to its representative string + $current_ssl_bridging_str = $ssl_bridging_methods[$current_ssl_bridging] + + if ($current_ssl_bridging_str -ne $ssl_bridging) { + Set-Item -LiteralPath "RDS:\GatewayServer\SSLBridging" -Value ([array]::IndexOf($ssl_bridging_methods, $ssl_bridging)) -WhatIf:$check_mode + $diff_text += "-SSLBridging = $current_ssl_bridging_str`n+SSLBridging = $ssl_bridging`n" + $result.changed = $true + } +} + +if ($null -ne $enable_only_messaging_capable_clients) { + $current_enable_only_messaging_capable_clients = (Get-Item -LiteralPath "RDS:\GatewayServer\EnableOnlyMessagingCapableClients").CurrentValue + # Convert the integer value to boolean + $current_enable_only_messaging_capable_clients = $current_enable_only_messaging_capable_clients -eq 1 + + if ($current_enable_only_messaging_capable_clients -ne $enable_only_messaging_capable_clients) { + Set-Item -LiteralPath "RDS:\GatewayServer\EnableOnlyMessagingCapableClients" -Value ([int]$enable_only_messaging_capable_clients) -WhatIf:$check_mode + $diff_text += "-EnableOnlyMessagingCapableClients = $current_enable_only_messaging_capable_clients`n" + $diff_text += "+EnableOnlyMessagingCapableClients = $enable_only_messaging_capable_clients`n" + $result.changed = $true + } +} + +if ($diff_mode -and $result.changed -eq $true) { + $result.diff = @{ + prepared = $diff_text + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_rds_settings.py b/ansible_collections/community/windows/plugins/modules/win_rds_settings.py new file mode 100644 index 00000000..e5370b05 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_rds_settings.py @@ -0,0 +1,57 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Subileau (@ksubileau) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_rds_settings +short_description: Manage main settings of a Remote Desktop Gateway server +description: + - Configure general settings of a Remote Desktop Gateway server. +author: + - Kevin Subileau (@ksubileau) +options: + certificate_hash: + description: + - Certificate hash (thumbprint) for the Remote Desktop Gateway server. The certificate hash is the unique identifier for the certificate. + type: str + max_connections: + description: + - The maximum number of connections allowed. + - If set to C(0), no new connections are allowed. + - If set to C(-1), the number of connections is unlimited. + type: int + ssl_bridging: + description: + - Specifies whether to use SSL Bridging. + - 'C(none): no SSL bridging.' + - 'C(https_http): HTTPS-HTTP bridging.' + - 'C(https_https): HTTPS-HTTPS bridging.' + type: str + choices: [ https_http, https_https, none ] + enable_only_messaging_capable_clients: + description: + - If enabled, only clients that support logon messages and administrator messages can connect. + type: bool +requirements: + - Windows Server 2008R2 (6.1) or higher. + - The Windows Feature "RDS-Gateway" must be enabled. +seealso: +- module: community.windows.win_rds_cap +- module: community.windows.win_rds_rap +- module: community.windows.win_rds_settings +''' + +EXAMPLES = r''' +- name: Configure the Remote Desktop Gateway + community.windows.win_rds_settings: + certificate_hash: B0D0FA8408FC67B230338FCA584D03792DA73F4C + max_connections: 50 + notify: + - Restart TSGateway service +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_region.ps1 b/ansible_collections/community/windows/plugins/modules/win_region.ps1 new file mode 100644 index 00000000..90713c12 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_region.ps1 @@ -0,0 +1,444 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + location = @{ type = "str" } + format = @{ type = "str" } + unicode_language = @{ type = "str" } + copy_settings = @{ type = "bool"; default = $false } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$check_mode = $module.CheckMode + +$location = $module.Params.location +$format = $module.Params.format +$unicode_language = $module.Params.unicode_language +$copy_settings = $module.Params.copy_settings + +$module.Result.restart_required = $false + +# This is used to get the format values based on the LCType enum based through. When running Vista/7/2008/200R2 +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.Text; +using System.Runtime.InteropServices; +using System.ComponentModel; + +namespace Ansible.WinRegion { + + public class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int GetLocaleInfoEx( + String lpLocaleName, + UInt32 LCType, + StringBuilder lpLCData, + int cchData); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int GetSystemDefaultLocaleName( + IntPtr lpLocaleName, + int cchLocaleName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int GetUserDefaultLocaleName( + IntPtr lpLocaleName, + int cchLocaleName); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + public static extern int RegLoadKeyW( + UInt32 hKey, + string lpSubKey, + string lpFile); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + public static extern int RegUnLoadKeyW( + UInt32 hKey, + string lpSubKey); + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class Hive : IDisposable + { + private const UInt32 SCOPE = 0x80000003; // HKU + private string hiveKey; + private bool loaded = false; + + public Hive(string hiveKey, string hivePath) + { + this.hiveKey = hiveKey; + int ret = NativeMethods.RegLoadKeyW(SCOPE, hiveKey, hivePath); + if (ret != 0) + throw new Win32Exception(ret, String.Format("Failed to load registry hive at {0}", hivePath)); + loaded = true; + } + + public static void UnloadHive(string hiveKey) + { + int ret = NativeMethods.RegUnLoadKeyW(SCOPE, hiveKey); + if (ret != 0) + throw new Win32Exception(ret, String.Format("Failed to unload registry hive at {0}", hiveKey)); + } + + public void Dispose() + { + if (loaded) + { + // Make sure the garbage collector disposes all unused handles and waits until it is complete + GC.Collect(); + GC.WaitForPendingFinalizers(); + + UnloadHive(hiveKey); + loaded = false; + } + GC.SuppressFinalize(this); + } + ~Hive() { this.Dispose(); } + } + + public class LocaleHelper { + private String Locale; + + public LocaleHelper(String locale) { + Locale = locale; + } + + public String GetValueFromType(UInt32 LCType) { + StringBuilder data = new StringBuilder(500); + int result = NativeMethods.GetLocaleInfoEx(Locale, LCType, data, 500); + if (result == 0) + throw new Win32Exception("Error getting locale info with legacy method"); + + return data.ToString(); + } + } +} +'@ + + +Function Get-LastWin32ExceptionMessage { + param([int]$ErrorCode) + $exp = New-Object -TypeName System.ComponentModel.Win32Exception -ArgumentList $ErrorCode + $exp_msg = "{0} (Win32 ErrorCode {1} - 0x{1:X8})" -f $exp.Message, $ErrorCode + return $exp_msg +} + +Function Get-SystemLocaleName { + $max_length = 85 # LOCALE_NAME_MAX_LENGTH + $ptr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($max_length) + + try { + $res = [Ansible.WinRegion.NativeMethods]::GetSystemDefaultLocaleName($ptr, $max_length) + + if ($res -eq 0) { + $err_code = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error() + $msg = Get-LastWin32ExceptionMessage -Error $err_code + $module.FailJson("Failed to get system locale: $msg") + } + + $system_locale = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) + } + finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) + } + + return $system_locale +} + +Function Get-UserLocaleName { + $max_length = 85 # LOCALE_NAME_MAX_LENGTH + $ptr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($max_length) + + try { + $res = [Ansible.WinRegion.NativeMethods]::GetUserDefaultLocaleName($ptr, $max_length) + + if ($res -eq 0) { + $err_code = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error() + $msg = Get-LastWin32ExceptionMessage -Error $err_code + $module.FailJson("Failed to get user locale: $msg") + } + + $user_locale = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) + } + finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) + } + + return $user_locale +} + +Function Get-ValidGeoId($cultures) { + $geo_ids = @() + foreach ($culture in $cultures) { + try { + $geo_id = [System.Globalization.RegionInfo]$culture.Name + $geo_ids += $geo_id.GeoId + } + catch {} + } + $geo_ids +} + +Function Test-RegistryProperty($reg_key, $property) { + $type = Get-ItemProperty -LiteralPath $reg_key -Name $property -ErrorAction SilentlyContinue + if ($null -eq $type) { + $false + } + else { + $true + } +} + +Function Copy-RegistryKey($source, $target) { + # Using Copy-Item -Recurse is giving me weird results, doing it recursively + Copy-Item -LiteralPath $source -Destination $target -WhatIf:$check_mode + + foreach ($key in Get-ChildItem -LiteralPath $source) { + $sourceKey = "$source\$($key.PSChildName)" + $targetKey = (Get-Item -LiteralPath $source).PSChildName + Copy-RegistryKey -source "$sourceKey" -target "$target\$targetKey" + } +} + +Function Set-UserLocale($culture) { + $reg_key = 'HKCU:\Control Panel\International' + + $lookup = New-Object Ansible.WinRegion.LocaleHelper($culture) + # hex values are from http://www.pinvoke.net/default.aspx/kernel32/GetLocaleInfoEx.html + $wanted_values = @{ + Locale = '{0:x8}' -f ([System.Globalization.CultureInfo]$culture).LCID + LocaleName = $culture + s1159 = $lookup.GetValueFromType(0x00000028) + s2359 = $lookup.GetValueFromType(0x00000029) + sCountry = $lookup.GetValueFromType(0x00000006) + sCurrency = $lookup.GetValueFromType(0x00000014) + sDate = $lookup.GetValueFromType(0x0000001D) + sDecimal = $lookup.GetValueFromType(0x0000000E) + sGrouping = $lookup.GetValueFromType(0x00000010) + sLanguage = $lookup.GetValueFromType(0x00000003) # LOCALE_ABBREVLANGNAME + sList = $lookup.GetValueFromType(0x0000000C) + sLongDate = $lookup.GetValueFromType(0x00000020) + sMonDecimalSep = $lookup.GetValueFromType(0x00000016) + sMonGrouping = $lookup.GetValueFromType(0x00000018) + sMonThousandSep = $lookup.GetValueFromType(0x00000017) + sNativeDigits = $lookup.GetValueFromType(0x00000013) + sNegativeSign = $lookup.GetValueFromType(0x00000051) + sPositiveSign = $lookup.GetValueFromType(0x00000050) + sShortDate = $lookup.GetValueFromType(0x0000001F) + sThousand = $lookup.GetValueFromType(0x0000000F) + sTime = $lookup.GetValueFromType(0x0000001E) + sTimeFormat = $lookup.GetValueFromType(0x00001003) + sYearMonth = $lookup.GetValueFromType(0x00001006) + iCalendarType = $lookup.GetValueFromType(0x00001009) + iCountry = $lookup.GetValueFromType(0x00000005) + iCurrDigits = $lookup.GetValueFromType(0x00000019) + iCurrency = $lookup.GetValueFromType(0x0000001B) + iDate = $lookup.GetValueFromType(0x00000021) + iDigits = $lookup.GetValueFromType(0x00000011) + NumShape = $lookup.GetValueFromType(0x00001014) # LOCALE_IDIGITSUBSTITUTION + iFirstDayOfWeek = $lookup.GetValueFromType(0x0000100C) + iFirstWeekOfYear = $lookup.GetValueFromType(0x0000100D) + iLZero = $lookup.GetValueFromType(0x00000012) + iMeasure = $lookup.GetValueFromType(0x0000000D) + iNegCurr = $lookup.GetValueFromType(0x0000001C) + iNegNumber = $lookup.GetValueFromType(0x00001010) + iPaperSize = $lookup.GetValueFromType(0x0000100A) + iTime = $lookup.GetValueFromType(0x00000023) + iTimePrefix = $lookup.GetValueFromType(0x00001005) + iTLZero = $lookup.GetValueFromType(0x00000025) + } + + if (Test-RegistryProperty -reg_key $reg_key -property 'sShortTime') { + # sShortTime was added after Vista, will check anyway and add in the value if it exists + $wanted_values.sShortTime = $lookup.GetValueFromType(0x00000079) + } + + $properties = Get-ItemProperty -LiteralPath $reg_key + foreach ($property in $properties.PSObject.Properties) { + if (Test-RegistryProperty -reg_key $reg_key -property $property.Name) { + $name = $property.Name + $old_value = $property.Value + $new_value = $wanted_values.$name + + if ($new_value -ne $old_value) { + Set-ItemProperty -LiteralPath $reg_key -Name $name -Value $new_value -WhatIf:$check_mode + $module.Result.changed = $true + } + } + } +} + +Function Set-SystemLocaleLegacy($unicode_language) { + # For when Get/Set-WinSystemLocale is not available (Pre Windows 8 and Server 2012) + $current_language_value = (Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\Language').Default + $wanted_language_value = '{0:x4}' -f ([System.Globalization.CultureInfo]$unicode_language).LCID + if ($current_language_value -ne $wanted_language_value) { + Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\Language' -Name 'Default' -Value $wanted_language_value -WhatIf:$check_mode + $module.Result.changed = $true + $module.Result.restart_required = $true + } + + # This reads from the non registry (Default) key, the extra prop called (Default) see below for more details + $current_locale_value = (Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\Locale')."(Default)" + $wanted_locale_value = '{0:x8}' -f ([System.Globalization.CultureInfo]$unicode_language).LCID + if ($current_locale_value -ne $wanted_locale_value) { + # Need to use .net to write property value, Locale has 2 (Default) properties + # 1: The actual (Default) property, we don't want to change Set-ItemProperty writes to this value when using (Default) + # 2: A property called (Default), this is what we want to change and only .net SetValue can do this one + if (-not $check_mode) { + $hive = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey("LocalMachine", $env:COMPUTERNAME) + $key = $hive.OpenSubKey("SYSTEM\CurrentControlSet\Control\Nls\Locale", $true) + $key.SetValue("(Default)", $wanted_locale_value, [Microsoft.Win32.RegistryValueKind]::String) + } + $module.Result.changed = $true + $module.Result.restart_required = $true + } + + $codepage_path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' + $current_codepage_info = Get-ItemProperty -LiteralPath $codepage_path + $wanted_codepage_info = ([System.Globalization.CultureInfo]::GetCultureInfo($unicode_language)).TextInfo + + $current_a_cp = $current_codepage_info.ACP + $current_oem_cp = $current_codepage_info.OEMCP + $current_mac_cp = $current_codepage_info.MACCP + $wanted_a_cp = $wanted_codepage_info.ANSICodePage + $wanted_oem_cp = $wanted_codepage_info.OEMCodePage + $wanted_mac_cp = $wanted_codepage_info.MacCodePage + + if ($current_a_cp -ne $wanted_a_cp) { + Set-ItemProperty -LiteralPath $codepage_path -Name 'ACP' -Value $wanted_a_cp -WhatIf:$check_mode + $module.Result.changed = $true + $module.Result.restart_required = $true + } + if ($current_oem_cp -ne $wanted_oem_cp) { + Set-ItemProperty -LiteralPath $codepage_path -Name 'OEMCP' -Value $wanted_oem_cp -WhatIf:$check_mode + $module.Result.changed = $true + $module.Result.restart_required = $true + } + if ($current_mac_cp -ne $wanted_mac_cp) { + Set-ItemProperty -LiteralPath $codepage_path -Name 'MACCP' -Value $wanted_mac_cp -WhatIf:$check_mode + $module.Result.changed = $true + $module.Result.restart_required = $true + } +} + +if ($null -eq $format -and $null -eq $location -and $null -eq $unicode_language) { + $module.FailJson("An argument for 'format', 'location' or 'unicode_language' needs to be supplied") +} +else { + $valid_cultures = [System.Globalization.CultureInfo]::GetCultures('AllCultures') + $valid_geoids = Get-ValidGeoId -cultures $valid_cultures + + if ($null -ne $location) { + if ($valid_geoids -notcontains $location) { + $module.FailJson("The argument location '$location' does not contain a valid Geo ID") + } + } + + if ($null -ne $format) { + if ($valid_cultures.Name -notcontains $format) { + $module.FailJson("The argument format '$format' does not contain a valid Culture Name") + } + } + + if ($null -ne $unicode_language) { + if ($valid_cultures.Name -notcontains $unicode_language) { + $module.FailJson("The argument unicode_language '$unicode_language' does not contain a valid Culture Name") + } + } +} + +if ($null -ne $location) { + # Get-WinHomeLocation was only added in Server 2012 and above + # Use legacy option if older + if (Get-Command 'Get-WinHomeLocation' -ErrorAction SilentlyContinue) { + $current_location = (Get-WinHomeLocation).GeoId + if ($current_location -ne $location) { + if (-not $check_mode) { + Set-WinHomeLocation -GeoId $location + } + $module.Result.changed = $true + } + } + else { + $current_location = (Get-ItemProperty -LiteralPath 'HKCU:\Control Panel\International\Geo').Nation + if ($current_location -ne $location) { + Set-ItemProperty -LiteralPath 'HKCU:\Control Panel\International\Geo' -Name 'Nation' -Value $location -WhatIf:$check_mode + $module.Result.changed = $true + } + } +} + +if ($null -ne $format) { + # Cannot use Get/Set-Culture as that fails to get and set the culture when running in the PSRP runspace. + $current_format = Get-UserLocaleName + if ($current_format -ne $format) { + Set-UserLocale -culture $format + $module.Result.changed = $true + } +} + +if ($null -ne $unicode_language) { + # Get/Set-WinSystemLocale was only added in Server 2012 and above, use legacy option if older + if (Get-Command 'Get-WinSystemLocale' -ErrorAction SilentlyContinue) { + $current_unicode_language = Get-SystemLocaleName + if ($current_unicode_language -ne $unicode_language) { + if (-not $check_mode) { + Set-WinSystemLocale -SystemLocale $unicode_language + } + $module.Result.changed = $true + $module.Result.restart_required = $true + } + } + else { + Set-SystemLocaleLegacy -unicode_language $unicode_language + } +} + +if ($copy_settings -eq $true -and $module.Result.changed -eq $true) { + if (-not $check_mode) { + $null = New-PSDrive -Name HKU -PSProvider Registry -Root Registry::HKEY_USERS + + if (Test-Path -LiteralPath HKU:\ANSIBLE) { + $module.Warn("hive already loaded at HKU:\ANSIBLE, had to unload hive for win_region to continue") + [Ansible.WinRegion.Hive]::UnloadHive("ANSIBLE") + } + + $loaded_hive = New-Object -TypeName Ansible.WinRegion.Hive -ArgumentList "ANSIBLE", 'C:\Users\Default\NTUSER.DAT' + try { + $sids = 'ANSIBLE', '.DEFAULT', 'S-1-5-19', 'S-1-5-20' + foreach ($sid in $sids) { + Copy-RegistryKey -source "HKCU:\Keyboard Layout" -target "HKU:\$sid" + Copy-RegistryKey -source "HKCU:\Control Panel\International" -target "HKU:\$sid\Control Panel" + Copy-RegistryKey -source "HKCU:\Control Panel\Input Method" -target "HKU:\$sid\Control Panel" + } + } + finally { + $loaded_hive.Dispose() + } + + Remove-PSDrive HKU + } + $module.Result.changed = $true +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_region.py b/ansible_collections/community/windows/plugins/modules/win_region.py new file mode 100644 index 00000000..1c6200d8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_region.py @@ -0,0 +1,95 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Ansible, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +module: win_region +short_description: Set the region and format settings +description: + - Set the location settings of a Windows Server. + - Set the format settings of a Windows Server. + - Set the unicode language settings of a Windows Server. + - Copy across these settings to the default profile. +options: + location: + description: + - The location to set for the current user, see + U(https://msdn.microsoft.com/en-us/library/dd374073.aspx) + for a list of GeoIDs you can use and what location it relates to. + - This needs to be set if C(format) or C(unicode_language) is not + set. + type: str + format: + description: + - The language format to set for the current user, see + U(https://msdn.microsoft.com/en-us/library/system.globalization.cultureinfo.aspx) + for a list of culture names to use. + - This needs to be set if C(location) or C(unicode_language) is not set. + type: str + unicode_language: + description: + - The unicode language format to set for all users, see + U(https://msdn.microsoft.com/en-us/library/system.globalization.cultureinfo.aspx) + for a list of culture names to use. + - This needs to be set if C(location) or C(format) is not set. After setting this + value a reboot is required for it to take effect. + type: str + copy_settings: + description: + - This will copy the current format and location values to new user + profiles and the welcome screen. This will only run if + C(location), C(format) or C(unicode_language) has resulted in a + change. If this process runs then it will always result in a + change. + type: bool + default: no +seealso: +- module: community.windows.win_timezone +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Set the region format to English United States + community.windows.win_region: + format: en-US + +- name: Set the region format to English Australia and copy settings to new profiles + community.windows.win_region: + format: en-AU + copy_settings: yes + +- name: Set the location to United States + community.windows.win_region: + location: 244 + +# Reboot when region settings change +- name: Set the unicode language to English Great Britain, reboot if required + community.windows.win_region: + unicode_language: en-GB + register: result + +- ansible.windows.win_reboot: + when: result.restart_required + +# Reboot when format, location or unicode has changed +- name: Set format, location and unicode to English Australia and copy settings, reboot if required + community.windows.win_region: + location: 12 + format: en-AU + unicode_language: en-AU + register: result + +- ansible.windows.win_reboot: + when: result.restart_required +''' + +RETURN = r''' +restart_required: + description: Whether a reboot is required for the change to take effect. + returned: success + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_regmerge.ps1 b/ansible_collections/community/windows/plugins/modules/win_regmerge.ps1 new file mode 100644 index 00000000..8d511cc2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_regmerge.ps1 @@ -0,0 +1,99 @@ +#!powershell + +# Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil +#Requires -Module Ansible.ModuleUtils.Legacy + +Function Convert-RegistryPath { + Param ( + [parameter(Mandatory = $True)] + [ValidateNotNullOrEmpty()]$Path + ) + + $output = $Path -replace "HKLM:", "HKLM" + $output = $output -replace "HKCU:", "HKCU" + + Return $output +} + +$result = @{ + changed = $false +} +$params = Parse-Args $args + +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -resultobj $result +$compare_to = Get-AnsibleParam -obj $params -name "compare_to" -type "str" -resultobj $result + +# check it looks like a reg key, warn if key not present - will happen first time +# only accepting PS-Drive style key names (starting with HKLM etc, not HKEY_LOCAL_MACHINE etc) + +$do_comparison = $False + +If ($compare_to) { + $compare_to_key = $params.compare_to.ToString() + If (Test-Path $compare_to_key -pathType container ) { + $do_comparison = $True + } + Else { + $result.compare_to_key_found = $false + } +} + +If ( $do_comparison -eq $True ) { + $guid = [guid]::NewGuid() + $exported_path = $env:TEMP + "\" + $guid.ToString() + 'ansible_win_regmerge.reg' + + $expanded_compare_key = Convert-RegistryPath ($compare_to_key) + + # export from the reg key location to a file + $reg_args = Argv-ToString -Arguments @("reg.exe", "EXPORT", $expanded_compare_key, $exported_path) + $res = Run-Command -command $reg_args + if ($res.rc -ne 0) { + $result.rc = $res.rc + $result.stdout = $res.stdout + $result.stderr = $res.stderr + Fail-Json -obj $result -message "error exporting registry '$expanded_compare_key' to '$exported_path'" + } + + # compare the two files + $comparison_result = Compare-Object -ReferenceObject $(Get-Content $path) -DifferenceObject $(Get-Content $exported_path) + + If ($null -ne $comparison_result -and (Get-Member -InputObject $comparison_result -Name "count" -MemberType Properties )) { + # Something is different, actually do reg merge + $reg_import_args = Argv-ToString -Arguments @("reg.exe", "IMPORT", $path) + $res = Run-Command -command $reg_import_args + if ($res.rc -ne 0) { + $result.rc = $res.rc + $result.stdout = $res.stdout + $result.stderr = $res.stderr + Fail-Json -obj $result -message "error importing registry values from '$path'" + } + $result.changed = $true + $result.difference_count = $comparison_result.count + } + Else { + $result.difference_count = 0 + } + + Remove-Item $exported_path + $result.compared = $true + +} +Else { + # not comparing, merge and report changed + $reg_import_args = Argv-ToString -Arguments @("reg.exe", "IMPORT", $path) + $res = Run-Command -command $reg_import_args + if ($res.rc -ne 0) { + $result.rc = $res.rc + $result.stdout = $res.stdout + $result.stderr = $res.stderr + Fail-Json -obj $result -message "error importing registry value from '$path'" + } + $result.changed = $true + $result.compared = $false +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_regmerge.py b/ansible_collections/community/windows/plugins/modules/win_regmerge.py new file mode 100644 index 00000000..073f54c9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_regmerge.py @@ -0,0 +1,74 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_regmerge +short_description: Merges the contents of a registry file into the Windows registry +description: + - Wraps the reg.exe command to import the contents of a registry file. + - Suitable for use with registry files created using M(ansible.windows.win_template). + - Windows registry files have a specific format and must be constructed correctly with carriage return and line feed line endings otherwise they will not + be merged. + - Exported registry files often start with a Byte Order Mark which must be removed if the file is to templated using M(ansible.windows.win_template). + - Registry file format is described at U(https://support.microsoft.com/en-us/kb/310516) + - See also M(ansible.windows.win_template), M(ansible.windows.win_regedit) +options: + path: + description: + - The full path including file name to the registry file on the remote machine to be merged + type: path + required: yes + compare_key: + description: + - The parent key to use when comparing the contents of the registry to the contents of the file. Needs to be in HKLM or HKCU part of registry. + Use a PS-Drive style path for example HKLM:\SOFTWARE not HKEY_LOCAL_MACHINE\SOFTWARE + If not supplied, or the registry key is not found, no comparison will be made, and the module will report changed. + type: str +notes: + - Organise your registry files so that they contain a single root registry + key if you want to use the compare_to functionality. + - This module does not force registry settings to be in the state + described in the file. If registry settings have been modified externally + the module will merge the contents of the file but continue to report + differences on subsequent runs. + - To force registry change, use M(ansible.windows.win_regedit) with C(state=absent) before + using C(community.windows.win_regmerge). +seealso: +- module: ansible.windows.win_reg_stat +- module: ansible.windows.win_regedit +author: +- Jon Hawkesworth (@jhawkesworth) +''' + +EXAMPLES = r''' +- name: Merge in a registry file without comparing to current registry + community.windows.win_regmerge: + path: C:\autodeploy\myCompany-settings.reg + +- name: Compare and merge registry file + community.windows.win_regmerge: + path: C:\autodeploy\myCompany-settings.reg + compare_to: HKLM:\SOFTWARE\myCompany +''' + +RETURN = r''' +compare_to_key_found: + description: whether the parent registry key has been found for comparison + returned: when comparison key not found in registry + type: bool + sample: false +difference_count: + description: number of differences between the registry and the file + returned: changed + type: int + sample: 1 +compared: + description: whether a comparison has taken place between the registry and the file + returned: when a comparison key has been supplied and comparison has been attempted + type: bool + sample: true +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_robocopy.ps1 b/ansible_collections/community/windows/plugins/modules/win_robocopy.ps1 new file mode 100644 index 00000000..2d3e0580 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_robocopy.ps1 @@ -0,0 +1,148 @@ +#!powershell + +# Copyright: (c) 2015, Corwin Brown <corwin.brown@maxpoint.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$src = Get-AnsibleParam -obj $params -name "src" -type "path" -failifempty $true +$dest = Get-AnsibleParam -obj $params -name "dest" -type "path" -failifempty $true +$purge = Get-AnsibleParam -obj $params -name "purge" -type "bool" -default $false +$recurse = Get-AnsibleParam -obj $params -name "recurse" -type "bool" -default $false +$flags = Get-AnsibleParam -obj $params -name "flags" -type "str" + +$result = @{ + changed = $false + dest = $dest + purge = $purge + recurse = $recurse + src = $src +} + +# Search for an Error Message +# Robocopy seems to display an error after 3 '-----' separator lines +Function SearchForError($cmd_output, $default_msg) { + $separator_count = 0 + $error_msg = $default_msg + ForEach ($line in $cmd_output) { + if (-not $line) { + continue + } + + if ($separator_count -ne 3) { + if (Select-String -InputObject $line -pattern "^(\s+)?(\-+)(\s+)?$") { + $separator_count += 1 + } + } + else { + if (Select-String -InputObject $line -pattern "error") { + $error_msg = $line + break + } + } + } + + return $error_msg +} + +if (-not (Test-Path -Path $src)) { + Fail-Json $result "$src does not exist!" +} + +# Build Arguments +$robocopy_opts = @($src, $dest) + +if ($check_mode) { + $robocopy_opts += "/l" +} + +if ($null -eq $flags) { + if ($purge) { + $robocopy_opts += "/purge" + } + + if ($recurse) { + $robocopy_opts += "/e" + } +} +else { + ForEach ($f in $flags.split(" ")) { + $robocopy_opts += $f + } +} + +$result.flags = $flags +$result.cmd = "$robocopy $robocopy_opts" + +Try { + $robocopy_output = &robocopy $robocopy_opts + $rc = $LASTEXITCODE +} +Catch { + Fail-Json $result "Error synchronizing $src to $dest! Msg: $($_.Exception.Message)" +} + +$result.msg = "Success" +$result.output = $robocopy_output +$result.return_code = $rc # Backward compatibility +$result.rc = $rc + +switch ($rc) { + + 0 { + $result.msg = "No files copied." + } + 1 { + $result.msg = "Files copied successfully!" + $result.changed = $true + $result.failed = $false + } + 2 { + $result.msg = "Some Extra files or directories were detected. No files were copied." + Add-Warning $result $result.msg + $result.failed = $false + } + 3 { + $result.msg = "(2+1) Some files were copied. Additional files were present." + Add-Warning $result $result.msg + $result.changed = $true + $result.failed = $false + } + 4 { + $result.msg = "Some mismatched files or directories were detected. Housekeeping might be required!" + Add-Warning $result $result.msg + $result.changed = $true + $result.failed = $false + } + 5 { + $result.msg = "(4+1) Some files were copied. Some files were mismatched." + Add-Warning $result $result.msg + $result.changed = $true + $result.failed = $false + } + 6 { + $result.msg = "(4+2) Additional files and mismatched files exist. No files were copied." + $result.failed = $false + } + 7 { + $result.msg = "(4+1+2) Files were copied, a file mismatch was present, and additional files were present." + Add-Warning $result $result.msg + $result.changed = $true + $result.failed = $false + } + 8 { + Fail-Json $result (SearchForError $robocopy_output "Some files or directories could not be copied!") + } + { @(9, 10, 11, 12, 13, 14, 15) -contains $_ } { + Fail-Json $result (SearchForError $robocopy_output "Fatal error. Check log message!") + } + 16 { + Fail-Json $result (SearchForError $robocopy_output "Serious Error! No files were copied! Do you have permissions to access $src and $dest?") + } + +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_robocopy.py b/ansible_collections/community/windows/plugins/modules/win_robocopy.py new file mode 100644 index 00000000..21a91875 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_robocopy.py @@ -0,0 +1,139 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Corwin Brown <blakfeld@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_robocopy +short_description: Synchronizes the contents of two directories using Robocopy +description: +- Synchronizes the contents of files/directories from a source to destination. +- Under the hood this just calls out to RoboCopy, since that should be available + on most modern Windows systems. +options: + src: + description: + - Source file/directory to sync. + type: path + required: yes + dest: + description: + - Destination file/directory to sync (Will receive contents of src). + type: path + required: yes + recurse: + description: + - Includes all subdirectories (Toggles the C(/e) flag to RoboCopy). + - If C(flags) is set, this will be ignored. + type: bool + default: no + purge: + description: + - Deletes any files/directories found in the destination that do not exist in the source. + - Toggles the C(/purge) flag to RoboCopy. + - If C(flags) is set, this will be ignored. + type: bool + default: no + flags: + description: + - Directly supply Robocopy flags. + - If set, C(purge) and C(recurse) will be ignored. + type: str +notes: +- This is not a complete port of the M(ansible.posix.synchronize) module. Unlike the M(ansible.posix.synchronize) + module this only performs the sync/copy on the remote machine, not from the Ansible controller to the remote machine. +- This module does not currently support all Robocopy flags. +seealso: +- module: ansible.posix.synchronize +- module: ansible.windows.win_copy +author: +- Corwin Brown (@blakfeld) +''' + +EXAMPLES = r''' +- name: Sync the contents of one directory to another + community.windows.win_robocopy: + src: C:\DirectoryOne + dest: C:\DirectoryTwo + +- name: Sync the contents of one directory to another, including subdirectories + community.windows.win_robocopy: + src: C:\DirectoryOne + dest: C:\DirectoryTwo + recurse: yes + +- name: Sync the contents of one directory to another, and remove any files/directories found in destination that do not exist in the source + community.windows.win_robocopy: + src: C:\DirectoryOne + dest: C:\DirectoryTwo + purge: yes + +- name: Sync content in recursive mode, removing any files/directories found in destination that do not exist in the source + community.windows.win_robocopy: + src: C:\DirectoryOne + dest: C:\DirectoryTwo + recurse: yes + purge: yes + +- name: Sync two directories in recursive and purging mode, specifying additional special flags + community.windows.win_robocopy: + src: C:\DirectoryOne + dest: C:\DirectoryTwo + flags: /E /PURGE /XD SOME_DIR /XF SOME_FILE /MT:32 + +- name: Sync one file from a remote UNC path in recursive and purging mode, specifying additional special flags + community.windows.win_robocopy: + src: \\Server1\Directory One + dest: C:\DirectoryTwo + flags: file.zip /E /PURGE /XD SOME_DIR /XF SOME_FILE /MT:32 +''' + +RETURN = r''' +cmd: + description: The used command line. + returned: always + type: str + sample: robocopy C:\DirectoryOne C:\DirectoryTwo /e /purge +src: + description: The Source file/directory of the sync. + returned: always + type: str + sample: C:\Some\Path +dest: + description: The Destination file/directory of the sync. + returned: always + type: str + sample: C:\Some\Path +recurse: + description: Whether or not the recurse flag was toggled. + returned: always + type: bool + sample: false +purge: + description: Whether or not the purge flag was toggled. + returned: always + type: bool + sample: false +flags: + description: Any flags passed in by the user. + returned: always + type: str + sample: /e /purge +rc: + description: The return code returned by robocopy. + returned: success + type: int + sample: 1 +output: + description: The output of running the robocopy command. + returned: success + type: str + sample: "------------------------------------\\n ROBOCOPY :: Robust File Copy for Windows \\n------------------------------------\\n " +msg: + description: Output interpreted into a concise message. + returned: always + type: str + sample: No files copied! +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_route.ps1 b/ansible_collections/community/windows/plugins/modules/win_route.ps1 new file mode 100644 index 00000000..58129fa4 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_route.ps1 @@ -0,0 +1,112 @@ +#!powershell + +# Copyright: (c) 2016, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +# win_route (Add or remove a network static route) + +$params = Parse-Args $args -supports_check_mode $true + +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -default $false +$dest = Get-AnsibleParam -obj $params -name "destination" -type "str" -failifempty $true +$gateway = Get-AnsibleParam -obj $params -name "gateway" -type "str" +$metric = Get-AnsibleParam -obj $params -name "metric" -type "int" -default 1 +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateSet "present", "absent" +$result = @{ + "changed" = $false + "output" = "" +} + +Function Add-Route { + Param ( + [Parameter(Mandatory = $true)] + [string]$Destination, + [Parameter(Mandatory = $true)] + [string]$Gateway, + [Parameter(Mandatory = $true)] + [int]$Metric, + [Parameter(Mandatory = $true)] + [bool]$CheckMode + ) + + + $IpAddress = $Destination.split('/')[0] + + # Check if the static route is already present + $Route = Get-CimInstance win32_ip4PersistedrouteTable -Filter "Destination = '$($IpAddress)'" + if (!($Route)) { + try { + # Find Interface Index + $InterfaceIndex = Find-NetRoute -RemoteIPAddress $Gateway | Select-Object -First 1 -ExpandProperty InterfaceIndex + + # Add network route + $routeParams = @{ + DestinationPrefix = $Destination + NextHop = $Gateway + InterfaceIndex = $InterfaceIndex + RouteMetric = $Metric + ErrorAction = "Stop" + WhatIf = $CheckMode + } + New-NetRoute @routeParams | Out-Null + $result.changed = $true + $result.output = "Route added" + + } + catch { + $ErrorMessage = $_.Exception.Message + Fail-Json $result $ErrorMessage + } + } + else { + $result.output = "Static route already exists" + } + +} + +Function Remove-Route { + Param ( + [Parameter(Mandatory = $true)] + [string]$Destination, + [bool]$CheckMode + ) + $IpAddress = $Destination.split('/')[0] + $Route = Get-CimInstance win32_ip4PersistedrouteTable -Filter "Destination = '$($IpAddress)'" + if ($Route) { + try { + + Remove-NetRoute -DestinationPrefix $Destination -Confirm:$false -ErrorAction Stop -WhatIf:$CheckMode + $result.changed = $true + $result.output = "Route removed" + } + catch { + $ErrorMessage = $_.Exception.Message + Fail-Json $result $ErrorMessage + } + } + else { + $result.output = "No route to remove" + } + +} + +# Set gateway if null +if (!($gateway)) { + $gateway = "0.0.0.0" +} + + +if ($state -eq "present") { + + Add-Route -Destination $dest -Gateway $gateway -Metric $metric -CheckMode $check_mode + +} +else { + + Remove-Route -Destination $dest -CheckMode $check_mode + +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_route.py b/ansible_collections/community/windows/plugins/modules/win_route.py new file mode 100644 index 00000000..655ec11e --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_route.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Daniele Lazzari <lazzari@mailup.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_route +short_description: Add or remove a static route +description: + - Add or remove a static route. +options: + destination: + description: + - Destination IP address in CIDR format (ip address/prefix length). + type: str + required: yes + gateway: + description: + - The gateway used by the static route. + - If C(gateway) is not provided it will be set to C(0.0.0.0). + type: str + metric: + description: + - Metric used by the static route. + type: int + default: 1 + state: + description: + - If C(absent), it removes a network static route. + - If C(present), it adds a network static route. + type: str + choices: [ absent, present ] + default: present +notes: + - Works only with Windows 2012 R2 and newer. +author: +- Daniele Lazzari (@dlazz) +''' + +EXAMPLES = r''' +--- +- name: Add a network static route + community.windows.win_route: + destination: 192.168.2.10/32 + gateway: 192.168.1.1 + metric: 1 + state: present + +- name: Remove a network static route + community.windows.win_route: + destination: 192.168.2.10/32 + state: absent +''' +RETURN = r''' +output: + description: A message describing the task result. + returned: always + type: str + sample: "Route added" +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_say.ps1 b/ansible_collections/community/windows/plugins/modules/win_say.ps1 new file mode 100644 index 00000000..4f66430e --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_say.ps1 @@ -0,0 +1,108 @@ +#!powershell + +# Copyright: (c) 2016, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + msg = @{ type = "str" } + msg_file = @{ type = "path" } + start_sound_path = @{ type = "path" } + end_sound_path = @{ type = "path" } + voice = @{ type = "str" } + speech_speed = @{ type = "int"; default = 0 } + } + mutually_exclusive = @( + , @('msg', 'msg_file') + ) + required_one_of = @( + , @('msg', 'msg_file', 'start_sound_path', 'end_sound_path') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + + +$msg = $module.Params.msg +$msg_file = $module.Params.msg_file +$start_sound_path = $module.Params.start_sound_path +$end_sound_path = $module.Params.end_sound_path +$voice = $module.Params.voice +$speech_speed = $module.Params.speech_speed + +if ($speech_speed -lt -10 -or $speech_speed -gt 10) { + $module.FailJson("speech_speed needs to be an integer in the range -10 to 10. The value $speech_speed is outside this range.") +} + +$words = $null + +if ($msg_file) { + if (-not (Test-Path -LiteralPath $msg_file)) { + $msg = -join @( + "Message file $msg_file could not be found or opened. " + "Ensure you have specified the full path to the file, and the ansible windows user has permission to read the file." + ) + $module.FailJson($msg) + } + $words = Get-Content -LiteralPath $msg_file | Out-String +} + +if ($msg) { + $words = $msg +} + +if ($start_sound_path) { + if (-not (Test-Path -LiteralPath $start_sound_path)) { + $msg = -join @( + "Start sound file $start_sound_path could not be found or opened. " + "Ensure you have specified the full path to the file, and the ansible windows user has permission to read the file." + ) + $module.FailJson($msg) + } + if (-not $module.CheckMode) { + (new-object Media.SoundPlayer $start_sound_path).playSync() + } +} + +if ($words) { + Add-Type -AssemblyName System.speech + $tts = New-Object System.Speech.Synthesis.SpeechSynthesizer + if ($voice) { + try { + $tts.SelectVoice($voice) + } + catch [System.Management.Automation.MethodInvocationException] { + $module.Result.voice_info = "Could not load voice '$voice', using system default voice." + $module.Warn("Could not load voice '$voice', using system default voice.") + } + } + + $module.Result.voice = $tts.Voice.Name + if ($speech_speed -ne 0) { + $tts.Rate = $speech_speed + } + if (-not $module.CheckMode) { + $tts.Speak($words) + } + $tts.Dispose() +} + +if ($end_sound_path) { + if (-not (Test-Path -LiteralPath $end_sound_path)) { + $msg = -join @( + "End sound file $start_sound_path could not be found or opened. " + "Ensure you have specified the full path to the file, and the ansible windows user has permission to read the file." + ) + $module.FailJson($msg) + } + if (-not $module.CheckMode) { + (new-object Media.SoundPlayer $end_sound_path).playSync() + } +} + +$module.Result.message_text = $words.ToString() + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_say.py b/ansible_collections/community/windows/plugins/modules/win_say.py new file mode 100644 index 00000000..6ee107b7 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_say.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_say +short_description: Text to speech module for Windows to speak messages and optionally play sounds +description: + - Uses .NET libraries to convert text to speech and optionally play .wav sounds. Audio Service needs to be running and some kind of speakers or + headphones need to be attached to the windows target(s) for the speech to be audible. +options: + msg: + description: + - The text to be spoken. + - Use either C(msg) or C(msg_file). + - Optional so that you can use this module just to play sounds. + type: str + msg_file: + description: + - Full path to a windows format text file containing the text to be spoken. + - Use either C(msg) or C(msg_file). + - Optional so that you can use this module just to play sounds. + type: path + voice: + description: + - Which voice to use. See notes for how to discover installed voices. + - If the requested voice is not available the default voice will be used. + Example voice names from Windows 10 are C(Microsoft Zira Desktop) and C(Microsoft Hazel Desktop). + type: str + speech_speed: + description: + - How fast or slow to speak the text. + - Must be an integer value in the range -10 to 10. + - -10 is slowest, 10 is fastest. + type: int + default: 0 + start_sound_path: + description: + - Full path to a C(.wav) file containing a sound to play before the text is spoken. + - Useful on conference calls to alert other speakers that ansible has something to say. + type: path + end_sound_path: + description: + - Full path to a C(.wav) file containing a sound to play after the text has been spoken. + - Useful on conference calls to alert other speakers that ansible has finished speaking. + type: path +notes: + - Needs speakers or headphones to do anything useful. + - | + To find which voices are installed, run the following Powershell commands. + + Add-Type -AssemblyName System.Speech + $speech = New-Object -TypeName System.Speech.Synthesis.SpeechSynthesizer + $speech.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo } + $speech.Dispose() + + - Speech can be surprisingly slow, so it's best to keep message text short. +seealso: +- module: community.windows.win_msg +- module: community.windows.win_toast +author: +- Jon Hawkesworth (@jhawkesworth) +''' + +EXAMPLES = r''' +- name: Warn of impending deployment + community.windows.win_say: + msg: Warning, deployment commencing in 5 minutes, please log out. + +- name: Using a different voice and a start sound + community.windows.win_say: + start_sound_path: C:\Windows\Media\ding.wav + msg: Warning, deployment commencing in 5 minutes, please log out. + voice: Microsoft Hazel Desktop + +- name: With start and end sound + community.windows.win_say: + start_sound_path: C:\Windows\Media\Windows Balloon.wav + msg: New software installed + end_sound_path: C:\Windows\Media\chimes.wav + +- name: Text from file example + community.windows.win_say: + start_sound_path: C:\Windows\Media\Windows Balloon.wav + msg_file: AppData\Local\Temp\morning_report.txt + end_sound_path: C:\Windows\Media\chimes.wav +''' + +RETURN = r''' +message_text: + description: The text that the module attempted to speak. + returned: success + type: str + sample: "Warning, deployment commencing in 5 minutes." +voice: + description: The voice used to speak the text. + returned: success + type: str + sample: Microsoft Hazel Desktop +voice_info: + description: The voice used to speak the text. + returned: when requested voice could not be loaded + type: str + sample: Could not load voice TestVoice, using system default voice +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_scheduled_task.ps1 b/ansible_collections/community/windows/plugins/modules/win_scheduled_task.ps1 new file mode 100644 index 00000000..02cd012f --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scheduled_task.ps1 @@ -0,0 +1,1234 @@ +#!powershell + +# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com> +# Copyright: (c) 2015, Michael Perzel <michaelperzel@gmail.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.SID + +$ErrorActionPreference = "Stop" + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false +$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$path = Get-AnsibleParam -obj $params -name "path" -type "str" -default "\" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present" + +# task actions, list of dicts [{path, arguments, working_directory}] +$actions = Get-AnsibleParam -obj $params -name "actions" -type "list" + +# task triggers, list of dicts [{ type, ... }] +$triggers = Get-AnsibleParam -obj $params -name "triggers" -type "list" + +# task Principal properties +$display_name = Get-AnsibleParam -obj $params -name "display_name" -type "str" +$group = Get-AnsibleParam -obj $params -name "group" -type "str" +$logon_options = "none", "password", "s4u", "interactive_token", "group", "service_account", "interactive_token_or_password" +$logon_type = Get-AnsibleParam -obj $params -name "logon_type" -type "str" -validateset $logon_options +$run_level = Get-AnsibleParam -obj $params -name "run_level" -type "str" -validateset "limited", "highest" -aliases "runlevel" +$username = Get-AnsibleParam -obj $params -name "username" -type "str" -aliases "user" +$password = Get-AnsibleParam -obj $params -name "password" -type "str" +$update_password = Get-AnsibleParam -obj $params -name "update_password" -type "bool" -default $true + +# task RegistrationInfo properties +$author = Get-AnsibleParam -obj $params -name "author" -type "str" +$date = Get-AnsibleParam -obj $params -name "date" -type "str" +$description = Get-AnsibleParam -obj $params -name "description" -type "str" +$source = Get-AnsibleParam -obj $params -name "source" -type "str" +$version = Get-AnsibleParam -obj $params -name "version" -type "str" + +# task Settings properties +$allow_demand_start = Get-AnsibleParam -obj $params -name "allow_demand_start" -type "bool" +$allow_hard_terminate = Get-AnsibleParam -obj $params -name "allow_hard_terminate" -type "bool" +# https://msdn.microsoft.com/en-us/library/windows/desktop/aa383486(v=vs.85).aspx +$compatibility = Get-AnsibleParam -obj $params -name "compatibility" -type "int" +$delete_expired_task_after = Get-AnsibleParam -obj $params -name "delete_expired_task_after" -type "str" # time string PT... +$disallow_start_if_on_batteries = Get-AnsibleParam -obj $params -name "disallow_start_if_on_batteries" -type "bool" +$enabled = Get-AnsibleParam -obj $params -name "enabled" -type "bool" +$execution_time_limit = Get-AnsibleParam -obj $params -name "execution_time_limit" -type "str" # PT72H +$hidden = Get-AnsibleParam -obj $params -name "hidden" -type "bool" +# TODO: support for $idle_settings, needs to be created as a COM object +# https://msdn.microsoft.com/en-us/library/windows/desktop/aa383507(v=vs.85).aspx +$multiple_instances = Get-AnsibleParam -obj $params -name "multiple_instances" -type "int" +# TODO: support for $network_settings, needs to be created as a COM object +# https://msdn.microsoft.com/en-us/library/windows/desktop/aa383512(v=vs.85).aspx +$priority = Get-AnsibleParam -obj $params -name "priority" -type "int" +$restart_count = Get-AnsibleParam -obj $params -name "restart_count" -type "int" +$restart_interval = Get-AnsibleParam -obj $params -name "restart_interval" -type "str" # time string PT.. +$run_only_if_idle = Get-AnsibleParam -obj $params -name "run_only_if_idle" -type "bool" +$run_only_if_network_available = Get-AnsibleParam -obj $params -name "run_only_if_network_available" -type "bool" +$start_when_available = Get-AnsibleParam -obj $params -name "start_when_available" -type "bool" +$stop_if_going_on_batteries = Get-AnsibleParam -obj $params -name "stop_if_going_on_batteries" -type "bool" +$wake_to_run = Get-AnsibleParam -obj $params -name "wake_to_run" -type "bool" + +$result = @{ + changed = $false +} + +if ($diff_mode) { + $result.diff = @{} +} + +Add-CSharpType -TempPath $_remote_tmp -References @' +public enum TASK_ACTION_TYPE // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383553(v=vs.85).aspx +{ + TASK_ACTION_EXEC = 0, + // The below are not supported and are only kept for documentation purposes + TASK_ACTION_COM_HANDLER = 5, + TASK_ACTION_SEND_EMAIL = 6, + TASK_ACTION_SHOW_MESSAGE = 7 +} + +public enum TASK_CREATION // https://msdn.microsoft.com/en-us/library/windows/desktop/aa382538(v=vs.85).aspx +{ + TASK_VALIDATE_ONLY = 0x1, + TASK_CREATE = 0x2, + TASK_UPDATE = 0x4, + TASK_CREATE_OR_UPDATE = 0x6, + TASK_DISABLE = 0x8, + TASK_DONT_ADD_PRINCIPAL_ACE = 0x10, + TASK_IGNORE_REGISTRATION_TRIGGERS = 0x20 +} + +public enum TASK_LOGON_TYPE // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383566(v=vs.85).aspx +{ + TASK_LOGON_NONE = 0, + TASK_LOGON_PASSWORD = 1, + TASK_LOGON_S4U = 2, + TASK_LOGON_INTERACTIVE_TOKEN = 3, + TASK_LOGON_GROUP = 4, + TASK_LOGON_SERVICE_ACCOUNT = 5, + TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD = 6 +} + +public enum TASK_RUN_LEVEL // https://msdn.microsoft.com/en-us/library/windows/desktop/aa380747(v=vs.85).aspx +{ + TASK_RUNLEVEL_LUA = 0, + TASK_RUNLEVEL_HIGHEST = 1 +} + +public enum TASK_TRIGGER_TYPE2 // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383915(v=vs.85).aspx +{ + TASK_TRIGGER_EVENT = 0, + TASK_TRIGGER_TIME = 1, + TASK_TRIGGER_DAILY = 2, + TASK_TRIGGER_WEEKLY = 3, + TASK_TRIGGER_MONTHLY = 4, + TASK_TRIGGER_MONTHLYDOW = 5, + TASK_TRIGGER_IDLE = 6, + TASK_TRIGGER_REGISTRATION = 7, + TASK_TRIGGER_BOOT = 8, + TASK_TRIGGER_LOGON = 9, + TASK_TRIGGER_SESSION_STATE_CHANGE = 11 +} + +public enum TASK_SESSION_STATE_CHANGE_TYPE // https://docs.microsoft.com/en-us/windows/win32/api/taskschd/ne-taskschd-task_session_state_change_type +{ + TASK_CONSOLE_CONNECT = 1, + TASK_CONSOLE_DISCONNECT = 2, + TASK_REMOTE_CONNECT = 3, + TASK_REMOTE_DISCONNECT = 4, + TASK_SESSION_LOCK = 7, + TASK_SESSION_UNLOCK = 8 +} +'@ + +######################## +### HELPER FUNCTIONS ### +######################## +Function Convert-SnakeToPascalCase($snake) { + # very basic function to convert snake_case to PascalCase for use in COM + # objects + [regex]$regex = "_(\w)" + $pascal_case = $regex.Replace($snake, { $args[0].Value.Substring(1).ToUpper() }) + $capitalised = $pascal_case.Substring(0, 1).ToUpper() + $pascal_case.Substring(1) + + return $capitalised +} + +Function Compare-Property($property_name, $parent_property, $map, $enum_map = $null) { + $changes = [System.Collections.ArrayList]@() + + # loop through the passed in map and compare values + # Name = The name of property in the COM object + # Value = The new value to compare the existing value with + foreach ($entry in $map.GetEnumerator()) { + $new_value = $entry.Value + + if ($null -ne $new_value) { + $property_name = $entry.Name + $existing_value = $parent_property.$property_name + if ($existing_value -cne $new_value) { + try { + $parent_property.$property_name = $new_value + } + catch { + Fail-Json -obj $result -message "failed to set $property_name property '$property_name' to '$new_value': $($_.Exception.Message)" + } + + if ($null -ne $enum_map -and $enum_map.ContainsKey($property_name)) { + $enum = [type]$enum_map.$property_name + $existing_value = [Enum]::ToObject($enum, $existing_value) + $new_value = [Enum]::ToObject($enum, $new_value) + } + [void]$changes.Add("-$property_name=$existing_value`n+$property_name=$new_value") + } + } + } + + return , $changes +} + +Function Set-PropertyForComObject($com_object, $name, $arg, $value) { + $com_name = Convert-SnakeToPascalCase -snake $arg + try { + $com_object.$com_name = $value + } + catch { + Fail-Json -obj $result -message "failed to set $name property '$com_name' to '$value': $($_.Exception.Message)" + } +} + +Function Compare-PropertyList { + Param( + $collection, # the collection COM object to manipulate, this must contains the Create method + [string]$property_name, # human friendly name of the property object, e.g. action/trigger + [Array]$new, # a list of new properties, passed in by Ansible + [Array]$existing, # a list of existing properties from the COM object collection + [Hashtable]$map, # metadata for the collection, see below for the structure + [string]$enum # the parent enum name for type value + ) + <## map metadata structure + { + collection type [TASK_ACTION_TYPE] for Actions or [TASK_TRIGGER_TYPE2] for Triggers { + mandatory = list of mandatory properties for this type, ansible input name not the COM name + optional = list of optional properties that could be set for this type + # maps the ansible input object name to the COM name, e.g. working_directory = WorkingDirectory + map = { + ansible input name = COM name + } + } + }##> + # used by both Actions and Triggers to compare the collections of that property + + $enum = [type]$enum + $changes = [System.Collections.ArrayList]@() + $new_count = $new.Count + $existing_count = $existing.Count + + for ($i = 0; $i -lt $new_count; $i++) { + if ($i -lt $existing_count) { + $existing_property = $existing[$i] + } + else { + $existing_property = $null + } + $new_property = $new[$i] + + # get the type of the property, for action this is set automatically + if (-not $new_property.ContainsKey("type")) { + Fail-Json -obj $result -message "entry for $property_name must contain a type key" + } + $type = $new_property.type + $valid_types = $map.Keys + $property_map = $map.$type + + # now let's validate the args for the property + $mandatory_args = $property_map.mandatory + $optional_args = $property_map.optional + $total_args = $mandatory_args + $optional_args + + # validate the mandatory arguments + foreach ($mandatory_arg in $mandatory_args) { + if (-not $new_property.ContainsKey($mandatory_arg)) { + $msg = "mandatory key '$mandatory_arg' for $($property_name) is not set, mandatory keys are '$($mandatory_args -join "', '")'" + Fail-Json -obj $result -message $msg + } + } + # throw a warning if in invalid key was set + foreach ($entry in $new_property.GetEnumerator()) { + $key = $entry.Name + if ($key -notin $total_args -and $key -ne "type") { + $msg = "key '$key' for $($property_name) entry is not valid and will be ignored, valid keys are '$($total_args -join "', '")'" + Add-Warning -obj $result -message $msg + } + } + + # now we have validated the input and have gotten the metadata, let's + # get the diff string + if ($null -eq $existing_property) { + # we have more properties than before,just add to the new + # properties list + $diff_list = [System.Collections.ArrayList]@() + + foreach ($property_arg in $total_args) { + if ($new_property.ContainsKey($property_arg)) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $property_value = $new_property.$property_arg + + if ($property_value -is [Hashtable]) { + foreach ($kv in $property_value.GetEnumerator()) { + $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key + $sub_property_value = $kv.Value + [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value") + } + } + else { + [void]$diff_list.Add("+$com_name=$property_value") + } + } + } + + [void]$changes.Add("+$property_name[$i] = {`n +Type=$type`n $($diff_list -join ",`n ")`n+}") + } + elseif ([Enum]::ToObject($enum, $existing_property.Type) -ne $type) { + # the types are different so we need to change + $diff_list = [System.Collections.ArrayList]@() + + if ($existing_property.Type -notin $valid_types) { + [void]$diff_list.Add("-UNKNOWN TYPE $($existing_property.Type)") + foreach ($property_args in $total_args) { + if ($new_property.ContainsKey($property_arg)) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $property_value = $new_property.$property_arg + + if ($property_value -is [Hashtable]) { + foreach ($kv in $property_value.GetEnumerator()) { + $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key + $sub_property_value = $kv.Value + [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value") + } + } + else { + [void]$diff_list.Add("+$com_name=$property_value") + } + } + } + } + else { + # we know the types of the existing property + $existing_type = [Enum]::ToObject([TASK_TRIGGER_TYPE2], $existing_property.Type) + [void]$diff_list.Add("-Type=$existing_type") + [void]$diff_list.Add("+Type=$type") + foreach ($property_arg in $total_args) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $property_value = $new_property.$property_arg + $existing_value = $existing_property.$com_name + + if ($property_value -is [Hashtable]) { + foreach ($kv in $property_value.GetEnumerator()) { + $sub_property_value = $kv.Value + $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key + $sub_existing_value = $existing_property.$com_name.$sub_com_name + + if ($null -ne $sub_property_value) { + [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value") + } + + if ($null -ne $sub_existing_value) { + [void]$diff_list.Add("-$com_name.$sub_com_name=$sub_existing_value") + } + } + } + else { + if ($null -ne $property_value) { + [void]$diff_list.Add("+$com_name=$property_value") + } + + if ($null -ne $existing_value) { + [void]$diff_list.Add("-$com_name=$existing_value") + } + } + } + } + + [void]$changes.Add("$property_name[$i] = {`n $($diff_list -join ",`n ")`n}") + } + else { + # compare the properties of existing and new + $diff_list = [System.Collections.ArrayList]@() + + foreach ($property_arg in $total_args) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $property_value = $new_property.$property_arg + $existing_value = $existing_property.$com_name + + if ($property_value -is [Hashtable]) { + foreach ($kv in $property_value.GetEnumerator()) { + $sub_property_value = $kv.Value + + if ($null -ne $sub_property_value) { + $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key + $sub_existing_value = $existing_property.$com_name.$sub_com_name + + if ($sub_property_value -cne $sub_existing_value) { + [void]$diff_list.Add("-$com_name.$sub_com_name=$sub_existing_value") + [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value") + } + } + } + } + elseif ($null -ne $property_value -and $property_value -cne $existing_value) { + [void]$diff_list.Add("-$com_name=$existing_value") + [void]$diff_list.Add("+$com_name=$property_value") + } + } + + if ($diff_list.Count -gt 0) { + [void]$changes.Add("$property_name[$i] = {`n $($diff_list -join ",`n ")`n}") + } + } + + # finally rebuild the new property collection + $new_object = $collection.Create($type) + foreach ($property_arg in $total_args) { + $new_value = $new_property.$property_arg + if ($new_value -is [Hashtable]) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $new_object_property = $new_object.$com_name + + foreach ($kv in $new_value.GetEnumerator()) { + $value = $kv.Value + if ($null -ne $value) { + Set-PropertyForComObject -com_object $new_object_property -name $property_name -arg $kv.Key -value $value + } + } + } + elseif ($null -ne $new_value) { + Set-PropertyForComObject -com_object $new_object -name $property_name -arg $property_arg -value $new_value + } + } + } + + # if there were any extra properties not in the new list, create diff str + if ($existing_count -gt $new_count) { + for ($i = $new_count; $i -lt $existing_count; $i++) { + $diff_list = [System.Collections.ArrayList]@() + $existing_property = $existing[$i] + $existing_type = [Enum]::ToObject($enum, $existing_property.Type) + + if ($map.ContainsKey($existing_type)) { + $property_map = $map.$existing_type + $property_args = $property_map.mandatory + $property_map.optional + + foreach ($property_arg in $property_args) { + $com_name = Convert-SnakeToPascalCase -snake $property_arg + $existing_value = $existing_property.$com_name + if ($null -ne $existing_value) { + [void]$diff_list.Add("-$com_name=$existing_value") + } + } + } + else { + [void]$diff_list.Add("-UNKNOWN TYPE $existing_type") + } + + [void]$changes.Add("-$property_name[$i] = {`n $($diff_list -join ",`n ")`n-}") + } + } + + return , $changes +} + +Function Compare-Action($task_definition) { + # compares the Actions property and returns a list of list of changed + # actions for use in a diff string + # ActionCollection - https://msdn.microsoft.com/en-us/library/windows/desktop/aa446804(v=vs.85).aspx + # Action - https://msdn.microsoft.com/en-us/library/windows/desktop/aa446803(v=vs.85).aspx + if ($null -eq $actions) { + return , [System.Collections.ArrayList]@() + } + + $task_actions = $task_definition.Actions + $existing_count = $task_actions.Count + + # because we clear the actions and re-add them to keep the order, we need + # to convert the existing actions to a new list. + # The Item property in actions starts at 1 + $existing_actions = [System.Collections.ArrayList]@() + for ($i = 1; $i -le $existing_count; $i++) { + [void]$existing_actions.Add($task_actions.Item($i)) + } + if ($existing_count -gt 0) { + $task_actions.Clear() + } + + $map = @{ + [TASK_ACTION_TYPE]::TASK_ACTION_EXEC = @{ + mandatory = @('path') + optional = @('arguments', 'working_directory') + } + } + $changes = Compare-PropertyList -collection $task_actions -property_name "action" -new $actions -existing $existing_actions -map $map -enum TASK_ACTION_TYPE + + return , $changes +} + +Function Compare-Principal($task_definition, $task_definition_xml) { + # compares the Principal property and returns a list of changed objects for + # use in a diff string + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382071(v=vs.85).aspx + $principal_map = @{ + DisplayName = $display_name + LogonType = $logon_type + RunLevel = $run_level + } + $enum_map = @{ + LogonType = "TASK_LOGON_TYPE" + RunLevel = "TASK_RUN_LEVEL" + } + $task_principal = $task_definition.Principal + $changes = Compare-Property -property_name "Principal" -parent_property $task_principal -map $principal_map -enum_map $enum_map + + # Principal.UserId and GroupId only returns the username portion of the + # username, skipping the domain or server name. This makes the + # comparison process useless so we need to parse the task XML to get + # the actual sid/username. Depending on OS version this could be the SID + # or it could be the username, we need to handle that accordingly + $principal_username_sid = $task_definition_xml.Task.Principals.Principal.UserId + if ($null -ne $principal_username_sid -and $principal_username_sid -notmatch "^S-\d-\d+(-\d+){1,14}(-\d+){0,1}$") { + $principal_username_sid = Convert-ToSID -account_name $principal_username_sid + } + $principal_group_sid = $task_definition_xml.Task.Principals.Principal.GroupId + if ($null -ne $principal_group_sid -and $principal_group_sid -notmatch "^S-\d-\d+(-\d+){1,14}(-\d+){0,1}$") { + $principal_group_sid = Convert-ToSID -account_name $principal_group_sid + } + + if ($null -ne $username_sid) { + $new_user_name = Convert-FromSid -sid $username_sid + if ($null -ne $principal_group_sid) { + $existing_account_name = Convert-FromSid -sid $principal_group_sid + [void]$changes.Add("-GroupId=$existing_account_name`n+UserId=$new_user_name") + $task_principal.UserId = $new_user_name + $task_principal.GroupId = $null + } + elseif ($null -eq $principal_username_sid) { + [void]$changes.Add("+UserId=$new_user_name") + $task_principal.UserId = $new_user_name + } + elseif ($principal_username_sid -ne $username_sid) { + $existing_account_name = Convert-FromSid -sid $principal_username_sid + [void]$changes.Add("-UserId=$existing_account_name`n+UserId=$new_user_name") + $task_principal.UserId = $new_user_name + } + } + if ($null -ne $group_sid) { + $new_group_name = Convert-FromSid -sid $group_sid + if ($null -ne $principal_username_sid) { + $existing_account_name = Convert-FromSid -sid $principal_username_sid + [void]$changes.Add("-UserId=$existing_account_name`n+GroupId=$new_group_name") + $task_principal.UserId = $null + $task_principal.GroupId = $new_group_name + } + elseif ($null -eq $principal_group_sid) { + [void]$changes.Add("+GroupId=$new_group_name") + $task_principal.GroupId = $new_group_name + } + elseif ($principal_group_sid -ne $group_sid) { + $existing_account_name = Convert-FromSid -sid $principal_group_sid + [void]$changes.Add("-GroupId=$existing_account_name`n+GroupId=$new_group_name") + $task_principal.GroupId = $new_group_name + } + } + + return , $changes +} + +Function Compare-RegistrationInfo($task_definition) { + # compares the RegistrationInfo property and returns a list of changed + # objects for use in a diff string + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382100(v=vs.85).aspx + $reg_info_map = @{ + Author = $author + Date = $date + Description = $description + Source = $source + Version = $version + } + $changes = Compare-Property -property_name "RegistrationInfo" -parent_property $task_definition.RegistrationInfo -map $reg_info_map + + return , $changes +} + +Function Compare-Setting($task_definition) { + # compares the task Settings property and returns a list of changed objects + # for use in a diff string + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383480(v=vs.85).aspx + $settings_map = @{ + AllowDemandStart = $allow_demand_start + AllowHardTerminate = $allow_hard_terminate + Compatibility = $compatibility + DeleteExpiredTaskAfter = $delete_expired_task_after + DisallowStartIfOnBatteries = $disallow_start_if_on_batteries + ExecutionTimeLimit = $execution_time_limit + Enabled = $enabled + Hidden = $hidden + # IdleSettings = $idle_settings # TODO: this takes in a COM object + MultipleInstances = $multiple_instances + # NetworkSettings = $network_settings # TODO: this takes in a COM object + Priority = $priority + RestartCount = $restart_count + RestartInterval = $restart_interval + RunOnlyIfIdle = $run_only_if_idle + RunOnlyIfNetworkAvailable = $run_only_if_network_available + StartWhenAvailable = $start_when_available + StopIfGoingOnBatteries = $stop_if_going_on_batteries + WakeToRun = $wake_to_run + } + $changes = Compare-Property -property_name "Settings" -parent_property $task_definition.Settings -map $settings_map + + return , $changes +} + +Function Compare-Trigger($task_definition) { + # compares the task Triggers property and returns a list of changed objects + # for use in a diff string + # TriggerCollection - https://msdn.microsoft.com/en-us/library/windows/desktop/aa383875(v=vs.85).aspx + # Trigger - https://msdn.microsoft.com/en-us/library/windows/desktop/aa383868(v=vs.85).aspx + if ($null -eq $triggers) { + return , [System.Collections.ArrayList]@() + } + + $task_triggers = $task_definition.Triggers + $existing_count = $task_triggers.Count + + # because we clear the actions and re-add them to keep the order, we need + # to convert the existing actions to a new list. + # The Item property in actions starts at 1 + $existing_triggers = [System.Collections.ArrayList]@() + for ($i = 1; $i -le $existing_count; $i++) { + [void]$existing_triggers.Add($task_triggers.Item($i)) + } + if ($existing_count -gt 0) { + $task_triggers.Clear() + } + + $map = @{ + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_BOOT = @{ + mandatory = @() + optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_DAILY = @{ + mandatory = @('start_boundary') + optional = @('days_interval', 'enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_EVENT = @{ + mandatory = @('subscription') + # TODO: ValueQueries is a COM object + optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_IDLE = @{ + mandatory = @() + optional = @('enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_LOGON = @{ + mandatory = @() + optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'user_id', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_MONTHLYDOW = @{ + mandatory = @('start_boundary') + # Make sure run_on_last_week_of_month comes after weeks_of_month + # https://github.com/ansible-collections/community.windows/issues/414 + optional = @('days_of_week', 'enabled', 'end_boundary', 'execution_time_limit', 'months_of_year', 'random_delay', 'weeks_of_month', + 'run_on_last_week_of_month', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_MONTHLY = @{ + mandatory = @('days_of_month', 'start_boundary') + optional = @('enabled', 'end_boundary', 'execution_time_limit', 'months_of_year', 'random_delay', 'run_on_last_day_of_month', + 'start_boundary', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_REGISTRATION = @{ + mandatory = @() + optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_TIME = @{ + mandatory = @('start_boundary') + optional = @('enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_WEEKLY = @{ + mandatory = @('days_of_week', 'start_boundary') + optional = @('enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'weeks_interval', 'repetition') + } + [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_SESSION_STATE_CHANGE = @{ + mandatory = @() + optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'repetition', 'start_boundary', 'state_change', 'user_id' ) + } + } + $compareParams = @{ + collection = $task_triggers + property_name = "trigger" + new = $triggers + existing = $existing_triggers + map = $map + enum = "TASK_TRIGGER_TYPE2" + } + $changes = Compare-PropertyList @compareParams + + return , $changes +} + +Function Test-TaskExist($task_folder, $name) { + # checks if a task exists in the TaskFolder COM object, returns null if the + # task does not exist, otherwise returns the RegisteredTask object + $task = $null + if ($task_folder) { + $raw_tasks = $task_folder.GetTasks(1) # 1 = TASK_ENUM_HIDDEN + + for ($i = 1; $i -le $raw_tasks.Count; $i++) { + if ($raw_tasks.Item($i).Name -eq $name) { + $task = $raw_tasks.Item($i) + break + } + } + } + + return $task +} + +Function Test-XmlDurationFormat($key, $value) { + # validate value is in the Duration Data Type format + # PnYnMnDTnHnMnS + try { + $time_span = [System.Xml.XmlConvert]::ToTimeSpan($value) + return $time_span + } + catch [System.FormatException] { + Fail-Json -obj $result -message "trigger option '$key' must be in the XML duration format but was '$value'" + } +} + +###################################### +### VALIDATION/BUILDING OF OPTIONS ### +###################################### + +# invalid characters in task name +$invalid_name_chars = '\/:*?"<>|' +$invalid_name_chars_regex = "[$([regex]::Escape($invalid_name_chars))]" + +if ($name -cmatch $invalid_name_chars_regex) { + Fail-Json -obj $result -message "Invalid task name '$name'. The following characters are not valid: $invalid_name_chars" +} + +# convert username and group to SID if set +$username_sid = $null +if ($username) { + $username_sid = Convert-ToSID -account_name $username +} +$group_sid = $null +if ($group) { + $group_sid = Convert-ToSID -account_name $group +} + +# validate store_password and logon_type +if ($null -ne $logon_type) { + $full_enum_name = "TASK_LOGON_$($logon_type.ToUpper())" + $logon_type = [TASK_LOGON_TYPE]::$full_enum_name +} + +# now validate the logon_type option with the other parameters +if ($null -ne $username -and $null -ne $group) { + Fail-Json -obj $result -message "username and group can not be set at the same time" +} +if ($null -ne $logon_type) { + if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_S4U -and $null -eq $password) { + Fail-Json -obj $result -message "password must be set when logon_type=s4u" + } + + if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_GROUP -and $null -eq $group) { + Fail-Json -obj $result -message "group must be set when logon_type=group" + } + + # SIDs == Local System, Local Service and Network Service + if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_SERVICE_ACCOUNT -and $username_sid -notin @("S-1-5-18", "S-1-5-19", "S-1-5-20")) { + Fail-Json -obj $result -message "username must be SYSTEM, LOCAL SERVICE or NETWORK SERVICE when logon_type=service_account" + } +} + +# convert the run_level to enum value +if ($null -ne $run_level) { + if ($run_level -eq "limited") { + $run_level = [TASK_RUN_LEVEL]::TASK_RUNLEVEL_LUA + } + else { + $run_level = [TASK_RUN_LEVEL]::TASK_RUNLEVEL_HIGHEST + } +} + +# manually add the only support action type for each action - also convert PSCustomObject to Hashtable +for ($i = 0; $i -lt $actions.Count; $i++) { + $action = $actions[$i] + $action.type = [TASK_ACTION_TYPE]::TASK_ACTION_EXEC + if (-not $action.ContainsKey("path")) { + Fail-Json -obj $result -message "action entry must contain the key 'path'" + } + $actions[$i] = $action +} + +# convert and validate the triggers - and convert PSCustomObject to Hashtable +for ($i = 0; $i -lt $triggers.Count; $i++) { + $trigger = $triggers[$i] + $valid_trigger_types = @('event', 'time', 'daily', 'weekly', 'monthly', 'monthlydow', 'idle', 'registration', 'boot', 'logon', 'session_state_change') + if (-not $trigger.ContainsKey("type")) { + Fail-Json -obj $result -message "a trigger entry must contain a key 'type' with a value of '$($valid_trigger_types -join "', '")'" + } + + $trigger_type = $trigger.type + if ($trigger_type -notin $valid_trigger_types) { + $msg = "the specified trigger type '$trigger_type' is not valid, type must be a value of '$($valid_trigger_types -join "', '")'" + Fail-Json -obj $result -message $msg + } + + $full_enum_name = "TASK_TRIGGER_$($trigger_type.ToUpper())" + $trigger_type = [TASK_TRIGGER_TYPE2]::$full_enum_name + $trigger.type = $trigger_type + + $date_properties = @('start_boundary', 'end_boundary') + foreach ($property_name in $date_properties) { + # validate the date is in the DateTime format + # yyyy-mm-ddThh:mm:ss + if ($trigger.ContainsKey($property_name)) { + $date_value = $trigger.$property_name + try { + $date = Get-Date -Date $date_value -Format "yyyy-MM-dd'T'HH:mm:ssK" + # make sure we convert it to the full string format + $trigger.$property_name = $date.ToString() + } + catch [System.Management.Automation.ParameterBindingException] { + Fail-Json -obj $result -message "trigger option '$property_name' must be in the format 'YYYY-MM-DDThh:mm:ss' format but was '$date_value'" + } + } + } + + $time_properties = @('execution_time_limit', 'delay', 'random_delay') + foreach ($property_name in $time_properties) { + if ($trigger.ContainsKey($property_name)) { + $time_span = $trigger.$property_name + Test-XmlDurationFormat -key $property_name -value $time_span + } + } + + if ($trigger.ContainsKey("repetition")) { + if ($trigger.repetition -is [Array]) { + # Legacy doesn't natively support deprecate by date, need to do this manually until we use Ansible.Basic + if (-not $result.ContainsKey('deprecations')) { + $result.deprecations = @() + } + $result.deprecations += @{ + msg = "repetition is a list, should be defined as a dict" + date = "2021-07-01" + collection_name = "community.windows" + } + $trigger.repetition = $trigger.repetition[0] + } + + $interval_timespan = $null + if ($trigger.repetition.ContainsKey("interval") -and $null -ne $trigger.repetition.interval) { + $interval_timespan = Test-XmlDurationFormat -key "interval" -value $trigger.repetition.interval + } + + $duration_timespan = $null + if ($trigger.repetition.ContainsKey("duration") -and $null -ne $trigger.repetition.duration) { + $duration_timespan = Test-XmlDurationFormat -key "duration" -value $trigger.repetition.duration + } + + if ($null -ne $interval_timespan -and $null -ne $duration_timespan -and $interval_timespan -gt $duration_timespan) { + $msg = -join @( + "trigger repetition option 'interval' value '$($trigger.repetition.interval)' " + "must be less than or equal to 'duration' value '$($trigger.repetition.duration)'" + ) + Fail-Json -obj $result -message $msg + } + } + + # convert out human readble text to the hex values for these properties + if ($trigger.ContainsKey("days_of_week")) { + $days = $trigger.days_of_week + if ($days -is [String]) { + $days = $days.Split(",").Trim() + } + elseif ($days -isnot [Array]) { + $days = @($days) + } + + $day_value = 0 + foreach ($day in $days) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382057(v=vs.85).aspx + switch ($day) { + sunday { $day_value = $day_value -bor 0x01 } + monday { $day_value = $day_value -bor 0x02 } + tuesday { $day_value = $day_value -bor 0x04 } + wednesday { $day_value = $day_value -bor 0x08 } + thursday { $day_value = $day_value -bor 0x10 } + friday { $day_value = $day_value -bor 0x20 } + saturday { $day_value = $day_value -bor 0x40 } + default { Fail-Json -obj $result -message "invalid day of week '$day', check the spelling matches the full day name" } + } + } + if ($day_value -eq 0) { + $day_value = $null + } + + $trigger.days_of_week = $day_value + } + if ($trigger.ContainsKey("days_of_month")) { + $days = $trigger.days_of_month + if ($days -is [String]) { + $days = $days.Split(",").Trim() + } + elseif ($days -isnot [Array]) { + $days = @($days) + } + + $day_value = 0 + foreach ($day in $days) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382063(v=vs.85).aspx + switch ($day) { + 1 { $day_value = $day_value -bor 0x01 } + 2 { $day_value = $day_value -bor 0x02 } + 3 { $day_value = $day_value -bor 0x04 } + 4 { $day_value = $day_value -bor 0x08 } + 5 { $day_value = $day_value -bor 0x10 } + 6 { $day_value = $day_value -bor 0x20 } + 7 { $day_value = $day_value -bor 0x40 } + 8 { $day_value = $day_value -bor 0x80 } + 9 { $day_value = $day_value -bor 0x100 } + 10 { $day_value = $day_value -bor 0x200 } + 11 { $day_value = $day_value -bor 0x400 } + 12 { $day_value = $day_value -bor 0x800 } + 13 { $day_value = $day_value -bor 0x1000 } + 14 { $day_value = $day_value -bor 0x2000 } + 15 { $day_value = $day_value -bor 0x4000 } + 16 { $day_value = $day_value -bor 0x8000 } + 17 { $day_value = $day_value -bor 0x10000 } + 18 { $day_value = $day_value -bor 0x20000 } + 19 { $day_value = $day_value -bor 0x40000 } + 20 { $day_value = $day_value -bor 0x80000 } + 21 { $day_value = $day_value -bor 0x100000 } + 22 { $day_value = $day_value -bor 0x200000 } + 23 { $day_value = $day_value -bor 0x400000 } + 24 { $day_value = $day_value -bor 0x800000 } + 25 { $day_value = $day_value -bor 0x1000000 } + 26 { $day_value = $day_value -bor 0x2000000 } + 27 { $day_value = $day_value -bor 0x4000000 } + 28 { $day_value = $day_value -bor 0x8000000 } + 29 { $day_value = $day_value -bor 0x10000000 } + 30 { $day_value = $day_value -bor 0x20000000 } + 31 { $day_value = $day_value -bor 0x40000000 } + default { Fail-Json -obj $result -message "invalid day of month '$day', please specify numbers from 1-31" } + } + } + if ($day_value -eq 0) { + $day_value = $null + } + $trigger.days_of_month = $day_value + } + if ($trigger.ContainsKey("weeks_of_month")) { + $weeks = $trigger.weeks_of_month + if ($weeks -is [String]) { + $weeks = $weeks.Split(",").Trim() + } + elseif ($weeks -isnot [Array]) { + $weeks = @($weeks) + } + + $week_value = 0 + foreach ($week in $weeks) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382061(v=vs.85).aspx + switch ($week) { + 1 { $week_value = $week_value -bor 0x01 } + 2 { $week_value = $week_value -bor 0x02 } + 3 { $week_value = $week_value -bor 0x04 } + 4 { $week_value = $week_value -bor 0x08 } + default { Fail-Json -obj $result -message "invalid week of month '$week', please specify weeks from 1-4" } + } + + } + if ($week_value -eq 0) { + $week_value = $null + } + $trigger.weeks_of_month = $week_value + } + if ($trigger.ContainsKey("months_of_year")) { + $months = $trigger.months_of_year + if ($months -is [String]) { + $months = $months.Split(",").Trim() + } + elseif ($months -isnot [Array]) { + $months = @($months) + } + + $month_value = 0 + foreach ($month in $months) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382064(v=vs.85).aspx + switch ($month) { + january { $month_value = $month_value -bor 0x01 } + february { $month_value = $month_value -bor 0x02 } + march { $month_value = $month_value -bor 0x04 } + april { $month_value = $month_value -bor 0x08 } + may { $month_value = $month_value -bor 0x10 } + june { $month_value = $month_value -bor 0x20 } + july { $month_value = $month_value -bor 0x40 } + august { $month_value = $month_value -bor 0x80 } + september { $month_value = $month_value -bor 0x100 } + october { $month_value = $month_value -bor 0x200 } + november { $month_value = $month_value -bor 0x400 } + december { $month_value = $month_value -bor 0x800 } + default { Fail-Json -obj $result -message "invalid month name '$month', please specify full month name" } + } + } + if ($month_value -eq 0) { + $month_value = $null + } + $trigger.months_of_year = $month_value + } + if ($trigger.ContainsKey("state_change")) { + $trigger.state_change = switch ($trigger.state_change) { + console_connect { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_CONSOLE_CONNECT } + console_disconnect { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_CONSOLE_DISCONNECT } + remote_connect { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_REMOTE_CONNECT } + remote_disconnect { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_REMOTE_DISCONNECT } + session_lock { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_SESSION_LOCK } + session_unlock { [TASK_SESSION_STATE_CHANGE_TYPE]::TASK_SESSION_UNLOCK } + default { + Fail-Json -obj $result -message "invalid state_change '$($trigger.state_change)'" + } + } + } + $triggers[$i] = $trigger +} + +# add \ to start of path if it is not already there +if (-not $path.StartsWith("\")) { + $path = "\$path" +} +# ensure path does not end with \ if more than 1 char +if ($path.EndsWith("\") -and $path.Length -ne 1) { + $path = $path.Substring(0, $path.Length - 1) +} + +######################## +### START CODE BLOCK ### +######################## +$service = New-Object -ComObject Schedule.Service +try { + $service.Connect() +} +catch { + Fail-Json -obj $result -message "failed to connect to the task scheduler service: $($_.Exception.Message)" +} + +# check that the path for the task set exists, create if need be +try { + $task_folder = $service.GetFolder($path) +} +catch { + $task_folder = $null +} + +# try and get the task at the path +$task = Test-TaskExist -task_folder $task_folder -name $name +$task_path = Join-Path -Path $path -ChildPath $name + +if ($state -eq "absent") { + if ($null -ne $task) { + if (-not $check_mode) { + try { + $task_folder.DeleteTask($name, 0) + } + catch { + Fail-Json -obj $result -message "failed to delete task '$name' at path '$path': $($_.Exception.Message)" + } + } + if ($diff_mode) { + $result.diff.prepared = "-[Task]`n-$task_path`n" + } + $result.changed = $true + + # check if current folder has any more tasks + $other_tasks = $task_folder.GetTasks(1) # 1 = TASK_ENUM_HIDDEN + if ($other_tasks.Count -eq 0 -and $task_folder.Name -ne "\") { + try { + $task_folder.DeleteFolder($null, $null) + } + catch { + Fail-Json -obj $result -message "failed to delete empty task folder '$path' after task deletion: $($_.Exception.Message)" + } + } + } +} +else { + if ($null -eq $task) { + $create_diff_string = "+[Task]`n+$task_path`n`n" + # to create a bare minimum task we need 1 action + if ($null -eq $actions -or $actions.Count -eq 0) { + Fail-Json -obj $result -message "cannot create a task with no actions, set at least one action with a path to an executable" + } + + # Create a bare minimum task here, further properties will be set later on + $task_definition = $service.NewTask(0) + + # Set Actions info + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa446803(v=vs.85).aspx + $create_diff_string += "[Actions]`n" + $task_actions = $task_definition.Actions + foreach ($action in $actions) { + $create_diff_string += "+action[0] = {`n +Type=$([TASK_ACTION_TYPE]::TASK_ACTION_EXEC),`n +Path=$($action.path)`n" + $task_action = $task_actions.Create([TASK_ACTION_TYPE]::TASK_ACTION_EXEC) + $task_action.Path = $action.path + if ($null -ne $action.arguments) { + $create_diff_string += " +Arguments=$($action.arguments)`n" + $task_action.Arguments = $action.arguments + } + if ($null -ne $action.working_directory) { + $create_diff_string += " +WorkingDirectory=$($action.working_directory)`n" + $task_action.WorkingDirectory = $action.working_directory + } + $create_diff_string += "+}`n" + } + + # Register the new task + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382577(v=vs.85).aspx + if ($check_mode) { + # Only validate the task in check mode + $task_creation_flags = [TASK_CREATION]::TASK_VALIDATE_ONLY + } + else { + # Create the task but do not fire it as we still need to configure it further below + $task_creation_flags = [TASK_CREATION]::TASK_CREATE -bor [TASK_CREATION]::TASK_IGNORE_REGISTRATION_TRIGGERS + } + + # folder doesn't exist, need to create + if ($null -eq $task_folder) { + $task_folder = $service.GetFolder("\") + try { + if (-not $check_mode) { + $task_folder = $task_folder.CreateFolder($path) + } + } + catch { + Fail-Json -obj $result -message "failed to create new folder at path '$path': $($_.Exception.Message)" + } + } + + try { + $task = $task_folder.RegisterTaskDefinition($name, $task_definition, $task_creation_flags, $null, $null, $null) + } + catch { + Fail-Json -obj $result -message "failed to register new task definition: $($_.Exception.Message)" + } + if ($diff_mode) { + $result.diff.prepared = $create_diff_string + } + + $result.changed = $true + } + + # we cannot configure a task that was created above in check mode as it + # won't actually exist + if ($task) { + $task_definition = $task.Definition + $task_definition_xml = [xml]$task_definition.XmlText + + $action_changes = Compare-Action -task_definition $task_definition + $principal_changed = Compare-Principal -task_definition $task_definition -task_definition_xml $task_definition_xml + $reg_info_changed = Compare-RegistrationInfo -task_definition $task_definition + $settings_changed = Compare-Setting -task_definition $task_definition + $trigger_changes = Compare-Trigger -task_definition $task_definition + + # compile the diffs into one list with headers + $task_diff = [System.Collections.ArrayList]@() + if ($action_changes.Count -gt 0) { + [void]$task_diff.Add("[Actions]") + foreach ($action_change in $action_changes) { + [void]$task_diff.Add($action_change) + } + [void]$task_diff.Add("`n") + } + if ($principal_changed.Count -gt 0) { + [void]$task_diff.Add("[Principal]") + foreach ($principal_change in $principal_changed) { + [void]$task_diff.Add($principal_change) + } + [void]$task_diff.Add("`n") + } + if ($reg_info_changed.Count -gt 0) { + [void]$task_diff.Add("[Registration Info]") + foreach ($reg_info_change in $reg_info_changed) { + [void]$task_diff.Add($reg_info_change) + } + [void]$task_diff.Add("`n") + } + if ($settings_changed.Count -gt 0) { + [void]$task_diff.Add("[Settings]") + foreach ($settings_change in $settings_changed) { + [void]$task_diff.add($settings_change) + } + [void]$task_diff.Add("`n") + } + if ($trigger_changes.Count -gt 0) { + [void]$task_diff.Add("[Triggers]") + foreach ($trigger_change in $trigger_changes) { + [void]$task_diff.Add("$trigger_change") + } + [void]$task_diff.Add("`n") + } + + if ($null -ne $password -and (($update_password -eq $true) -or ($task_diff.Count -gt 0))) { + # because we can't compare the passwords we just need to reset it + $register_username = $username + $register_password = $password + $register_logon_type = $task_principal.LogonType + } + else { + # will inherit from the Principal property values + $register_username = $null + $register_password = $null + $register_logon_type = $null + } + + if ($task_diff.Count -gt 0 -or $null -ne $register_password) { + if ($check_mode) { + # Only validate the task in check mode + $task_creation_flags = [TASK_CREATION]::TASK_VALIDATE_ONLY + } + else { + # Create the task + $task_creation_flags = [TASK_CREATION]::TASK_CREATE_OR_UPDATE + } + try { + $task_folder.RegisterTaskDefinition( + $name, + $task_definition, + $task_creation_flags, + $register_username, + $register_password, + $register_logon_type + ) | Out-Null + } + catch { + Fail-Json -obj $result -message "failed to modify scheduled task: $($_.Exception.Message)" + } + + $result.changed = $true + + if ($diff_mode) { + $changed_diff_text = $task_diff -join "`n" + if ($null -ne $result.diff.prepared) { + $diff_text = "$($result.diff.prepared)`n$changed_diff_text" + } + else { + $diff_text = $changed_diff_text + } + $result.diff.prepared = $diff_text.Trim() + } + } + } +} + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_scheduled_task.py b/ansible_collections/community/windows/plugins/modules/win_scheduled_task.py new file mode 100644 index 00000000..d43089b8 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scheduled_task.py @@ -0,0 +1,539 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_scheduled_task +short_description: Manage scheduled tasks +description: +- Creates/modifies or removes Windows scheduled tasks. +options: + # module definition options + name: + description: + - The name of the scheduled task without the path. + type: str + required: yes + path: + description: + - Task folder in which this task will be stored. + - Will create the folder when C(state=present) and the folder does not + already exist. + - Will remove the folder when C(state=absent) and there are no tasks left + in the folder. + type: str + default: \ + state: + description: + - When C(state=present) will ensure the task exists. + - When C(state=absent) will ensure the task does not exist. + type: str + choices: [ absent, present ] + default: present + + # Action options + actions: + description: + - A list of action to configure for the task. + - See suboptions for details on how to construct each list entry. + - When creating a task there MUST be at least one action but when deleting + a task this can be a null or an empty list. + - The ordering of this list is important, the module will ensure the order + is kept when modifying the task. + - This module only supports the C(ExecAction) type but can still delete the + older legacy types. + type: list + elements: dict + suboptions: + path: + description: + - The path to the executable for the ExecAction. + type: str + required: yes + arguments: + description: + - An argument string to supply for the executable. + type: str + working_directory: + description: + - The working directory to run the executable from. + type: str + + # Trigger options + triggers: + description: + - A list of triggers to configure for the task. + - See suboptions for details on how to construct each list entry. + - The ordering of this list is important, the module will ensure the order + is kept when modifying the task. + - There are multiple types of triggers, see U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa383868.aspx) + for a list of trigger types and their options. + - The suboption options listed below are not required for all trigger + types, read the description for more details. + type: list + elements: dict + suboptions: + type: + description: + - The trigger type, this value controls what below options are + required. + type: str + required: yes + choices: [ boot, daily, event, idle, logon, monthlydow, monthly, registration, time, weekly, session_state_change ] + enabled: + description: + - Whether to set the trigger to enabled or disabled + - Used in all trigger types. + type: bool + start_boundary: + description: + - The start time for the task, even if the trigger meets the other + start criteria, it won't start until this time is met. + - If you wish to run a task at 9am on a day you still need to specify + the date on which the trigger is activated, you can set any date even + ones in the past. + - Required when C(type) is C(daily), C(monthlydow), C(monthly), + C(time), C(weekly). + - Optional for the rest of the trigger types. + - This is in ISO 8601 DateTime format C(YYYY-MM-DDThh:mm:ss). + type: str + end_boundary: + description: + - The end time for when the trigger is deactivated. + - This is in ISO 8601 DateTime format C(YYYY-MM-DDThh:mm:ss). + type: str + execution_time_limit: + description: + - The maximum amount of time that the task is allowed to run for. + - Optional for all the trigger types. + - Is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + delay: + description: + - The time to delay the task from running once the trigger has been + fired. + - Optional when C(type) is C(boot), C(event), C(logon), + C(registration), C(session_state_change). + - Is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + random_delay: + description: + - The delay time that is randomly added to the start time of the + trigger. + - Optional when C(type) is C(daily), C(monthlydow), C(monthly), + C(time), C(weekly). + - Is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + subscription: + description: + - Only used and is required for C(type=event). + - The XML query string that identifies the event that fires the + trigger. + type: str + user_id: + description: + - The username that the trigger will target. + - Optional when C(type) is C(logon), C(session_state_change). + - Can be the username or SID of a user. + - When C(type=logon) and you want the trigger to fire when a user in a + group logs on, leave this as null and set C(group) to the group you + wish to trigger. + type: str + days_of_week: + description: + - The days of the week for the trigger. + - Can be a list or comma separated string of full day names e.g. monday + instead of mon. + - Required when C(type) is C(weekly). + - Optional when C(type=monthlydow). + type: str + days_of_month: + description: + - The days of the month from 1 to 31 for the triggers. + - If you wish to set the trigger for the last day of any month + use C(run_on_last_day_of_month). + - Can be a list or comma separated string of day numbers. + - Required when C(type=monthly). + type: str + weeks_of_month: + description: + - The weeks of the month for the trigger. + - Can be a list or comma separated string of the numbers 1 to 4 + representing the first to 4th week of the month. + - Optional when C(type=monthlydow). + type: str + months_of_year: + description: + - The months of the year for the trigger. + - Can be a list or comma separated string of full month names e.g. + march instead of mar. + - Optional when C(type) is C(monthlydow), C(monthly). + type: str + run_on_last_week_of_month: + description: + - Boolean value that sets whether the task runs on the last week of the + month. + - Optional when C(type) is C(monthlydow). + type: bool + run_on_last_day_of_month: + description: + - Boolean value that sets whether the task runs on the last day of the + month. + - Optional when C(type) is C(monthly). + type: bool + weeks_interval: + description: + - The interval of weeks to run on, e.g. C(1) means every week while + C(2) means every other week. + - Optional when C(type=weekly). + type: int + repetition: + description: + - Allows you to define the repetition action of the trigger that defines how often the task is run and how long the repetition pattern is repeated + after the task is started. + - It takes in the following keys, C(duration), C(interval), C(stop_at_duration_end) + suboptions: + duration: + description: + - Defines how long the pattern is repeated. + - The value is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + - By default this is not set which means it will repeat indefinitely. + type: str + interval: + description: + - The amount of time between each restart of the task. + - The value is written in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + stop_at_duration_end: + description: + - Whether a running instance of the task is stopped at the end of the repetition pattern. + type: bool + state_change: + description: + - Allows you to define the kind of Terminal Server session change that triggers a task. + - Optional when C(type=session_state_change) + type: str + choices: + - console_connect + - console_disconnect + - remote_connect + - remote_disconnect + - session_lock + - session_unlock + version_added: 1.6.0 + + # Principal options + display_name: + description: + - The name of the user/group that is displayed in the Task Scheduler UI. + type: str + group: + description: + - The group that will run the task. + - C(group) and C(username) are exclusive to each other and cannot be set + at the same time. + - C(logon_type) can either be not set or equal C(group). + type: str + logon_type: + description: + - The logon method that the task will run with. + - C(password) means the password will be stored and the task has access + to network resources. + - C(s4u) means the existing token will be used to run the task and no + password will be stored with the task. Means no network or encrypted + files access. + - C(interactive_token) means the user must already be logged on + interactively and will run in an existing interactive session. + - C(group) means that the task will run as a group. + - C(service_account) means that a service account like System, Local + Service or Network Service will run the task. + type: str + choices: [ none, password, s4u, interactive_token, group, service_account, token_or_password ] + run_level: + description: + - The level of user rights used to run the task. + - If not specified the task will be created with limited rights. + type: str + choices: [ limited, highest ] + aliases: [ runlevel ] + username: + description: + - The user to run the scheduled task as. + - Will default to the current user under an interactive token if not + specified during creation. + - The user account specified must have the C(SeBatchLogonRight) logon right + which can be added with M(ansible.windows.win_user_right). + type: str + aliases: [ user ] + password: + description: + - The password for the user account to run the scheduled task as. + - This is required when running a task without the user being logged in, + excluding the builtin service accounts and Group Managed Service Accounts (gMSA). + - If set, will always result in a change unless C(update_password) is set + to C(no) and no other changes are required for the service. + type: str + update_password: + description: + - Whether to update the password even when not other changes have occurred. + - When C(yes) will always result in a change when executing the module. + type: bool + default: yes + + # RegistrationInfo options + author: + description: + - The author of the task. + type: str + date: + description: + - The date when the task was registered. + type: str + description: + description: + - The description of the task. + type: str + source: + description: + - The source of the task. + type: str + version: + description: + - The version number of the task. + type: str + + # Settings options + allow_demand_start: + description: + - Whether the task can be started by using either the Run command or the + Context menu. + type: bool + allow_hard_terminate: + description: + - Whether the task can be terminated by using TerminateProcess. + type: bool + compatibility: + description: + - The integer value with indicates which version of Task Scheduler a task + is compatible with. + - C(0) means the task is compatible with the AT command. + - C(1) means the task is compatible with Task Scheduler 1.0(Windows Vista, Windows Server 2008 and older). + - C(2) means the task is compatible with Task Scheduler 2.0(Windows Vista, Windows Server 2008). + - C(3) means the task is compatible with Task Scheduler 2.0(Windows 7, Windows Server 2008 R2). + - C(4) means the task is compatible with Task Scheduler 2.0(Windows 10, Windows Server 2016, Windows Server 2019). + type: int + choices: [ 0, 1, 2, 3, 4 ] + delete_expired_task_after: + description: + - The amount of time that the Task Scheduler will wait before deleting the + task after it expires. + - A task expires after the end_boundary has been exceeded for all triggers + associated with the task. + - This is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + disallow_start_if_on_batteries: + description: + - Whether the task will not be started if the computer is running on + battery power. + type: bool + enabled: + description: + - Whether the task is enabled, the task can only run when C(yes). + type: bool + execution_time_limit: + description: + - The amount of time allowed to complete the task. + - When set to C(PT0S), the time limit is infinite. + - When omitted, the default time limit is 72 hours. + - This is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + hidden: + description: + - Whether the task will be hidden in the UI. + type: bool + multiple_instances: + description: + - An integer that indicates the behaviour when starting a task that is + already running. + - C(0) will start a new instance in parallel with existing instances of + that task. + - C(1) will wait until other instances of that task to finish running + before starting itself. + - C(2) will not start a new instance if another is running. + - C(3) will stop other instances of the task and start the new one. + type: int + choices: [ 0, 1, 2, 3 ] + priority: + description: + - The priority level (0-10) of the task. + - When creating a new task the default is C(7). + - See U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa383512.aspx) + for details on the priority levels. + type: int + restart_count: + description: + - The number of times that the Task Scheduler will attempt to restart the + task. + type: int + restart_interval: + description: + - How long the Task Scheduler will attempt to restart the task. + - If this is set then C(restart_count) must also be set. + - The maximum allowed time is 31 days. + - The minimum allowed time is 1 minute. + - This is in the ISO 8601 Duration format C(P[n]Y[n]M[n]DT[n]H[n]M[n]S). + type: str + run_only_if_idle: + description: + - Whether the task will run the task only if the computer is in an idle + state. + type: bool + run_only_if_network_available: + description: + - Whether the task will run only when a network is available. + type: bool + start_when_available: + description: + - Whether the task can start at any time after its scheduled time has + passed. + type: bool + stop_if_going_on_batteries: + description: + - Whether the task will be stopped if the computer begins to run on battery + power. + type: bool + wake_to_run: + description: + - Whether the task will wake the computer when it is time to run the task. + type: bool +notes: +- The option names and structure for actions and triggers of a service follow + the C(RegisteredTask) naming standard and requirements, it would be useful to + read up on this guide if coming across any issues U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa382542.aspx). +- A Group Managed Service Account (gMSA) can be used by setting C(logon_type) to C(password) + and omitting the password parameter. For more information on gMSAs, + see U(https://techcommunity.microsoft.com/t5/Core-Infrastructure-and-Security/Windows-Server-2012-Group-Managed-Service-Accounts/ba-p/255910) +seealso: +- module: community.windows.win_scheduled_task_stat +- module: ansible.windows.win_user_right +author: +- Peter Mounce (@petemounce) +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create a task to open 2 command prompts as SYSTEM + community.windows.win_scheduled_task: + name: TaskName + description: open command prompt + actions: + - path: cmd.exe + arguments: /c hostname + - path: cmd.exe + arguments: /c whoami + triggers: + - type: daily + start_boundary: '2017-10-09T09:00:00' + username: SYSTEM + state: present + enabled: yes + +- name: Create task to run a PS script as NETWORK service on boot + community.windows.win_scheduled_task: + name: TaskName2 + description: Run a PowerShell script + actions: + - path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: -ExecutionPolicy Unrestricted -NonInteractive -File C:\TestDir\Test.ps1 + triggers: + - type: boot + username: NETWORK SERVICE + run_level: highest + state: present + +- name: Update Local Security Policy to allow users to run scheduled tasks + ansible.windows.win_user_right: + name: SeBatchLogonRight + users: + - LocalUser + - DOMAIN\NetworkUser + action: add + +- name: Change above task to run under a domain user account, storing the passwords + community.windows.win_scheduled_task: + name: TaskName2 + username: DOMAIN\User + password: Password + logon_type: password + +- name: Change the above task again, choosing not to store the password + community.windows.win_scheduled_task: + name: TaskName2 + username: DOMAIN\User + logon_type: s4u + +- name: Change above task to use a gMSA, where the password is managed automatically + community.windows.win_scheduled_task: + name: TaskName2 + username: DOMAIN\gMsaSvcAcct$ + logon_type: password + +- name: Create task with multiple triggers + community.windows.win_scheduled_task: + name: TriggerTask + path: \Custom + actions: + - path: cmd.exe + triggers: + - type: daily + - type: monthlydow + username: SYSTEM + +- name: Set logon type to password but don't force update the password + community.windows.win_scheduled_task: + name: TriggerTask + path: \Custom + actions: + - path: cmd.exe + username: Administrator + password: password + update_password: no + +- name: Disable a task that already exists + community.windows.win_scheduled_task: + name: TaskToDisable + enabled: no + +- name: Create a task that will be repeated every minute for five minutes + community.windows.win_scheduled_task: + name: RepeatedTask + description: open command prompt + actions: + - path: cmd.exe + arguments: /c hostname + triggers: + - type: registration + repetition: + interval: PT1M + duration: PT5M + stop_at_duration_end: yes + +- name: Create task to run a PS script in Windows 10 compatibility on boot with a delay of 1min + community.windows.win_scheduled_task: + name: TriggerTask + path: \Custom + actions: + - path: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + arguments: -ExecutionPolicy Unrestricted -NonInteractive -File C:\TestDir\Test.ps1 + triggers: + - type: boot + delay: PT1M + username: SYSTEM + compatibility: 4 +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.ps1 b/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.ps1 new file mode 100644 index 00000000..eb119ae0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.ps1 @@ -0,0 +1,398 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.CamelConversion + +$spec = @{ + options = @{ + path = @{ type = "str"; default = "\" } + name = @{ type = "str" } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$path = $module.Params.path +$name = $module.Params.name + +Function ConvertTo-NormalizedUser { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [String]$InputObject + ) + + $candidates = @(if ($InputObject.Contains('\')) { + $nameSplit = $InputObject.Split('\', 2) + + if ($nameSplit[0] -eq '.') { + # If the domain portion is . try using the hostname then falling back to just the username. + # Usually the hostname just works except when running on a DC where it's a domain account + # where looking up just the username should work. + , @($env:COMPUTERNAME, $nameSplit[1]) + $nameSplit[1] + } + else { + , $nameSplit + } + } + else { + $InputObject + }) + + $sid = for ($i = 0; $i -lt $candidates.Length; $i++) { + $candidate = $candidates[$i] + $ntAccount = New-Object -TypeName System.Security.Principal.NTAccount -ArgumentList $candidate + try { + $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]) + break + } + catch [System.Security.Principal.IdentityNotMappedException] { + if ($i -eq ($candidates.Length - 1)) { + throw + } + continue + } + } + + $sid.Translate([System.Security.Principal.NTAccount]).Value +} + +Add-CSharpType -AnsibleModule $module -References @' +public enum TASK_ACTION_TYPE +{ + TASK_ACTION_EXEC = 0, + // The below are not supported and are only kept for documentation purposes + TASK_ACTION_COM_HANDLER = 5, + TASK_ACTION_SEND_EMAIL = 6, + TASK_ACTION_SHOW_MESSAGE = 7 +} + +public enum TASK_LOGON_TYPE +{ + TASK_LOGON_NONE = 0, + TASK_LOGON_PASSWORD = 1, + TASK_LOGON_S4U = 2, + TASK_LOGON_INTERACTIVE_TOKEN = 3, + TASK_LOGON_GROUP = 4, + TASK_LOGON_SERVICE_ACCOUNT = 5, + TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD = 6 +} + +public enum TASK_RUN_LEVEL +{ + TASK_RUNLEVEL_LUA = 0, + TASK_RUNLEVEL_HIGHEST = 1 +} + +public enum TASK_STATE +{ + TASK_STATE_UNKNOWN = 0, + TASK_STATE_DISABLED = 1, + TASK_STATE_QUEUED = 2, + TASK_STATE_READY = 3, + TASK_STATE_RUNNING = 4 +} + +public enum TASK_TRIGGER_TYPE2 +{ + TASK_TRIGGER_EVENT = 0, + TASK_TRIGGER_TIME = 1, + TASK_TRIGGER_DAILY = 2, + TASK_TRIGGER_WEEKLY = 3, + TASK_TRIGGER_MONTHLY = 4, + TASK_TRIGGER_MONTHLYDOW = 5, + TASK_TRIGGER_IDLE = 6, + TASK_TRIGGER_REGISTRATION = 7, + TASK_TRIGGER_BOOT = 8, + TASK_TRIGGER_LOGON = 9, + TASK_TRIGGER_SESSION_STATE_CHANGE = 11 +} + +public enum TASK_SESSION_STATE_CHANGE_TYPE +{ + TASK_CONSOLE_CONNECT = 1, + TASK_CONSOLE_DISCONNECT = 2, + TASK_REMOTE_CONNECT = 3, + TASK_REMOTE_DISCONNECT = 4, + TASK_SESSION_LOCK = 7, + TASK_SESSION_UNLOCK = 8 +} +'@ + +Function Get-PropertyValue($task_property, $com, $property) { + $raw_value = $com.$property + + if ($null -eq $raw_value) { + return $null + } + elseif ($raw_value.GetType().Name -eq "__ComObject") { + $com_values = @{} + Get-Member -InputObject $raw_value -MemberType Property | ForEach-Object { + $com_value = Get-PropertyValue -task_property $property -com $raw_value -property $_.Name + $com_values.$($_.Name) = $com_value + } + + return , $com_values + } + + switch ($property) { + DaysOfWeek { + $value_list = @() + $map = @( + @{ day = "sunday"; bitwise = 0x01 } + @{ day = "monday"; bitwise = 0x02 } + @{ day = "tuesday"; bitwise = 0x04 } + @{ day = "wednesday"; bitwise = 0x08 } + @{ day = "thursday"; bitwise = 0x10 } + @{ day = "friday"; bitwise = 0x20 } + @{ day = "saturday"; bitwise = 0x40 } + ) + foreach ($entry in $map) { + $day = $entry.day + $bitwise = $entry.bitwise + if ($raw_value -band $bitwise) { + $value_list += $day + } + } + + $value = $value_list -join "," + break + } + DaysOfMonth { + $value_list = @() + $map = @( + @{ day = "1"; bitwise = 0x01 } + @{ day = "2"; bitwise = 0x02 } + @{ day = "3"; bitwise = 0x04 } + @{ day = "4"; bitwise = 0x08 } + @{ day = "5"; bitwise = 0x10 } + @{ day = "6"; bitwise = 0x20 } + @{ day = "7"; bitwise = 0x40 } + @{ day = "8"; bitwise = 0x80 } + @{ day = "9"; bitwise = 0x100 } + @{ day = "10"; bitwise = 0x200 } + @{ day = "11"; bitwise = 0x400 } + @{ day = "12"; bitwise = 0x800 } + @{ day = "13"; bitwise = 0x1000 } + @{ day = "14"; bitwise = 0x2000 } + @{ day = "15"; bitwise = 0x4000 } + @{ day = "16"; bitwise = 0x8000 } + @{ day = "17"; bitwise = 0x10000 } + @{ day = "18"; bitwise = 0x20000 } + @{ day = "19"; bitwise = 0x40000 } + @{ day = "20"; bitwise = 0x80000 } + @{ day = "21"; bitwise = 0x100000 } + @{ day = "22"; bitwise = 0x200000 } + @{ day = "23"; bitwise = 0x400000 } + @{ day = "24"; bitwise = 0x800000 } + @{ day = "25"; bitwise = 0x1000000 } + @{ day = "26"; bitwise = 0x2000000 } + @{ day = "27"; bitwise = 0x4000000 } + @{ day = "28"; bitwise = 0x8000000 } + @{ day = "29"; bitwise = 0x10000000 } + @{ day = "30"; bitwise = 0x20000000 } + @{ day = "31"; bitwise = 0x40000000 } + ) + + foreach ($entry in $map) { + $day = $entry.day + $bitwise = $entry.bitwise + if ($raw_value -band $bitwise) { + $value_list += $day + } + } + + $value = $value_list -join "," + break + } + WeeksOfMonth { + $value_list = @() + $map = @( + @{ week = "1"; bitwise = 0x01 } + @{ week = "2"; bitwise = 0x02 } + @{ week = "3"; bitwise = 0x04 } + @{ week = "4"; bitwise = 0x04 } + ) + + foreach ($entry in $map) { + $week = $entry.week + $bitwise = $entry.bitwise + if ($raw_value -band $bitwise) { + $value_list += $week + } + } + + $value = $value_list -join "," + break + } + MonthsOfYear { + $value_list = @() + $map = @( + @{ month = "january"; bitwise = 0x01 } + @{ month = "february"; bitwise = 0x02 } + @{ month = "march"; bitwise = 0x04 } + @{ month = "april"; bitwise = 0x08 } + @{ month = "may"; bitwise = 0x10 } + @{ month = "june"; bitwise = 0x20 } + @{ month = "july"; bitwise = 0x40 } + @{ month = "august"; bitwise = 0x80 } + @{ month = "september"; bitwise = 0x100 } + @{ month = "october"; bitwise = 0x200 } + @{ month = "november"; bitwise = 0x400 } + @{ month = "december"; bitwise = 0x800 } + ) + + foreach ($entry in $map) { + $month = $entry.month + $bitwise = $entry.bitwise + if ($raw_value -band $bitwise) { + $value_list += $month + } + } + + $value = $value_list -join "," + break + } + Type { + if ($task_property -eq "actions") { + $value = [Enum]::ToObject([TASK_ACTION_TYPE], $raw_value).ToString() + } + elseif ($task_property -eq "triggers") { + $value = [Enum]::ToObject([TASK_TRIGGER_TYPE2], $raw_value).ToString() + } + break + } + RunLevel { + $value = [Enum]::ToObject([TASK_RUN_LEVEL], $raw_value).ToString() + break + } + LogonType { + $value = [Enum]::ToObject([TASK_LOGON_TYPE], $raw_value).ToString() + break + } + UserId { + $value = ConvertTo-NormalizedUser -InputObject $raw_value + } + GroupId { + $value = ConvertTo-NormalizedUser -InputObject $raw_value + } + default { + $value = $raw_value + break + } + } + + return , $value +} + +$service = New-Object -ComObject Schedule.Service +try { + $service.Connect() +} +catch { + $module.FailJson("failed to connect to the task scheduler service: $($_.Exception.Message)", $_) +} + +try { + $task_folder = $service.GetFolder($path) + $module.Result.folder_exists = $true +} +catch { + $module.Result.folder_exists = $false + if ($null -ne $name) { + $module.Result.task_exists = $false + } + $module.ExitJson() +} + +$folder_tasks = $task_folder.GetTasks(1) +$folder_task_names = @() +$folder_task_count = 0 +$task = $null +for ($i = 1; $i -le $folder_tasks.Count; $i++) { + $task_name = $folder_tasks.Item($i).Name + $folder_task_names += $task_name + $folder_task_count += 1 + + if ($null -ne $name -and $task_name -eq $name) { + $task = $folder_tasks.Item($i) + } +} +$module.Result.folder_task_names = $folder_task_names +$module.Result.folder_task_count = $folder_task_count + +if ($null -ne $name) { + if ($null -ne $task) { + $module.Result.task_exists = $true + + # task state + $module.Result.state = @{ + last_run_time = (Get-Date $task.LastRunTime -Format s) + last_task_result = $task.LastTaskResult + next_run_time = (Get-Date $task.NextRunTime -Format s) + number_of_missed_runs = $task.NumberOfMissedRuns + status = [Enum]::ToObject([TASK_STATE], $task.State).ToString() + } + + # task definition + $task_definition = $task.Definition + $ignored_properties = @("XmlText") + $properties = @("principal", "registration_info", "settings") + $collection_properties = @("actions", "triggers") + + foreach ($property in $properties) { + $property_name = $property -replace "_" + $module.Result.$property = @{} + $values = $task_definition.$property_name + Get-Member -InputObject $values -MemberType Property | ForEach-Object { + if ($_.Name -notin $ignored_properties) { + $module.Result.$property.$($_.Name) = (Get-PropertyValue -task_property $property -com $values -property $_.Name) + } + } + } + + foreach ($property in $collection_properties) { + $module.Result.$property = @() + $collection = $task_definition.$property + $collection_count = $collection.Count + for ($i = 1; $i -le $collection_count; $i++) { + $item = $collection.Item($i) + $item_info = @{} + + Get-Member -InputObject $item -MemberType Property | ForEach-Object { + if ($_.Name -notin $ignored_properties) { + $value = (Get-PropertyValue -task_property $property -com $item -property $_.Name) + $item_info.$($_.Name) = $value + + # This was added after StateChange was represented by the raw enum value so we include both + # for backwards compatibility. + if ($_.Name -eq 'StateChange') { + $item_info.StateChangeStr = if ($value) { + [Enum]::ToObject([TASK_SESSION_STATE_CHANGE_TYPE], $value).ToString() + } + } + } + } + $module.Result.$property += $item_info + } + } + } + else { + $module.Result.task_exists = $false + } +} + +# Convert-DictToSnakeCase returns a Hashtable but Ansible.Basic expect a Dictionary. This is a hack until the snake +# conversion code has been moved to this collection and updated to handle this. +$new_result = [System.Collections.Generic.Dictionary[[String], [Object]]]@{} +foreach ($kvp in (Convert-DictToSnakeCase -dict $module.Result).GetEnumerator()) { + $new_result[$kvp.Name] = $kvp.Value +} +$module.Result = $new_result + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.py b/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.py new file mode 100644 index 00000000..f1922967 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scheduled_task_stat.py @@ -0,0 +1,371 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_scheduled_task_stat +short_description: Get information about Windows Scheduled Tasks +description: +- Will return whether the folder and task exists. +- Returns the names of tasks in the folder specified. +- Use M(community.windows.win_scheduled_task) to configure a scheduled task. +options: + path: + description: The folder path where the task lives. + type: str + default: \ + name: + description: + - The name of the scheduled task to get information for. + - If C(name) is set and exists, will return information on the task itself. + type: str +seealso: +- module: community.windows.win_scheduled_task +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Get information about a folder + community.windows.win_scheduled_task_stat: + path: \folder name + register: task_folder_stat + +- name: Get information about a task in the root folder + community.windows.win_scheduled_task_stat: + name: task name + register: task_stat + +- name: Get information about a task in a custom folder + community.windows.win_scheduled_task_stat: + path: \folder name + name: task name + register: task_stat +''' + +RETURN = r''' +actions: + description: A list of actions. + returned: name is specified and task exists + type: list + sample: [ + { + "Arguments": "/c echo hi", + "Id": null, + "Path": "cmd.exe", + "Type": "TASK_ACTION_EXEC", + "WorkingDirectory": null + } + ] +folder_exists: + description: Whether the folder set at path exists. + returned: always + type: bool + sample: true +folder_task_count: + description: The number of tasks that exist in the folder. + returned: always + type: int + sample: 2 +folder_task_names: + description: A list of tasks that exist in the folder. + returned: always + type: list + sample: [ 'Task 1', 'Task 2' ] +principal: + description: Details on the principal configured to run the task. + returned: name is specified and task exists + type: complex + contains: + display_name: + description: The name of the user/group that is displayed in the Task + Scheduler UI. + returned: '' + type: str + sample: Administrator + group_id: + description: The group that will run the task. + returned: '' + type: str + sample: BUILTIN\Administrators + id: + description: The ID for the principal. + returned: '' + type: str + sample: Author + logon_type: + description: The logon method that the task will run with. + returned: '' + type: str + sample: TASK_LOGON_INTERACTIVE_TOKEN + run_level: + description: The level of user rights used to run the task. + returned: '' + type: str + sample: TASK_RUNLEVEL_LUA + user_id: + description: The user that will run the task. + returned: '' + type: str + sample: SERVER\Administrator +registration_info: + description: Details on the task registration info. + returned: name is specified and task exists + type: complex + contains: + author: + description: The author os the task. + returned: '' + type: str + sample: SERVER\Administrator + date: + description: The date when the task was register. + returned: '' + type: str + sample: '2017-01-01T10:00:00' + description: + description: The description of the task. + returned: '' + type: str + sample: task description + documentation: + description: The documentation of the task. + returned: '' + type: str + sample: task documentation + security_descriptor: + description: The security descriptor of the task. + returned: '' + type: str + sample: security descriptor + source: + description: The source of the task. + returned: '' + type: str + sample: source + uri: + description: The URI/path of the task. + returned: '' + type: str + sample: \task\task name + version: + description: The version of the task. + returned: '' + type: str + sample: 1.0 +settings: + description: Details on the task settings. + returned: name is specified and task exists + type: complex + contains: + allow_demand_start: + description: Whether the task can be started by using either the Run + command of the Context menu. + returned: '' + type: bool + sample: true + allow_hard_terminate: + description: Whether the task can terminated by using TerminateProcess. + returned: '' + type: bool + sample: true + compatibility: + description: The compatibility level of the task + returned: '' + type: int + sample: 2 + delete_expired_task_after: + description: The amount of time the Task Scheduler will wait before + deleting the task after it expires. + returned: '' + type: str + sample: PT10M + disallow_start_if_on_batteries: + description: Whether the task will not be started if the computer is + running on battery power. + returned: '' + type: bool + sample: false + disallow_start_on_remote_app_session: + description: Whether the task will not be started when in a remote app + session. + returned: '' + type: bool + sample: true + enabled: + description: Whether the task is enabled. + returned: '' + type: bool + sample: true + execution_time_limit: + description: The amount of time allowed to complete the task. + returned: '' + type: str + sample: PT72H + hidden: + description: Whether the task is hidden in the UI. + returned: '' + type: bool + sample: false + idle_settings: + description: The idle settings of the task. + returned: '' + type: dict + sample: { + "idle_duration": "PT10M", + "restart_on_idle": false, + "stop_on_idle_end": true, + "wait_timeout": "PT1H" + } + maintenance_settings: + description: The maintenance settings of the task. + returned: '' + type: str + sample: null + mulitple_instances: + description: Indicates the behaviour when starting a task that is already + running. + returned: '' + type: int + sample: 2 + network_settings: + description: The network settings of the task. + returned: '' + type: dict + sample: { + "id": null, + "name": null + } + priority: + description: The priority level of the task. + returned: '' + type: int + sample: 7 + restart_count: + description: The number of times that the task will attempt to restart + on failures. + returned: '' + type: int + sample: 0 + restart_interval: + description: How long the Task Scheduler will attempt to restart the + task. + returned: '' + type: str + sample: PT15M + run_only_id_idle: + description: Whether the task will run if the computer is in an idle + state. + returned: '' + type: bool + sample: true + run_only_if_network_available: + description: Whether the task will run only when a network is available. + returned: '' + type: bool + sample: false + start_when_available: + description: Whether the task can start at any time after its scheduled + time has passed. + returned: '' + type: bool + sample: false + stop_if_going_on_batteries: + description: Whether the task will be stopped if the computer begins to + run on battery power. + returned: '' + type: bool + sample: true + use_unified_scheduling_engine: + description: Whether the task will use the unified scheduling engine. + returned: '' + type: bool + sample: false + volatile: + description: Whether the task is volatile. + returned: '' + type: bool + sample: false + wake_to_run: + description: Whether the task will wake the computer when it is time to + run the task. + returned: '' + type: bool + sample: false +state: + description: Details on the state of the task + returned: name is specified and task exists + type: complex + contains: + last_run_time: + description: The time the registered task was last run. + returned: '' + type: str + sample: '2017-09-20T20:50:00' + last_task_result: + description: The results that were returned the last time the task was + run. + returned: '' + type: int + sample: 267009 + next_run_time: + description: The time when the task is next scheduled to run. + returned: '' + type: str + sample: '2017-09-20T22:50:00' + number_of_missed_runs: + description: The number of times a task has missed a scheduled run. + returned: '' + type: int + sample: 1 + status: + description: The status of the task, whether it is running, stopped, etc. + returned: '' + type: str + sample: TASK_STATE_RUNNING +task_exists: + description: Whether the task at the folder exists. + returned: name is specified + type: bool + sample: true +triggers: + description: A list of triggers. + returned: name is specified and task exists + type: list + sample: [ + { + "delay": "PT15M", + "enabled": true, + "end_boundary": null, + "execution_time_limit": null, + "id": null, + "repetition": { + "duration": null, + "interval": null, + "stop_at_duration_end": false + }, + "start_boundary": null, + "type": "TASK_TRIGGER_BOOT" + }, + { + "days_of_month": "5,15,30", + "enabled": true, + "end_boundary": null, + "execution_time_limit": null, + "id": null, + "months_of_year": "june,december", + "random_delay": null, + "repetition": { + "duration": null, + "interval": null, + "stop_at_duration_end": false + }, + "run_on_last_day_of_month": true, + "start_boundary": "2017-09-20T03:44:38", + "type": "TASK_TRIGGER_MONTHLY" + } + ] +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_scoop.ps1 b/ansible_collections/community/windows/plugins/modules/win_scoop.ps1 new file mode 100644 index 00000000..e8383c82 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scoop.ps1 @@ -0,0 +1,285 @@ +#!powershell + +# Copyright: (c) 2020, Jamie Magee <jamie.magee@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + architecture = @{ type = "str"; choices = "32bit", "64bit"; aliases = @(, "arch") } + independent = @{ type = "bool"; default = $false } + global = @{ type = "bool"; default = $false } + name = @{ type = "list"; elements = "str"; required = $true } + no_cache = @{ type = "bool"; default = $false } + purge = @{ type = "bool"; default = $false } + skip_checksum = @{ type = "bool"; default = $false } + state = @{ type = "str"; default = "present"; choices = "present", "absent" } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$architecture = $module.Params.architecture +$independent = $module.Params.independent +$global = $module.Params.global +$name = $module.Params.name +$no_cache = $module.Params.no_cache +$purge = $module.Params.purge +$skip_checksum = $module.Params.skip_checksum +$state = $module.Params.state + +$module.Result.rc = 0 + +function Install-Scoop { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'This is one use case where we want to use iex')] + [CmdletBinding()] + param () + + # Scoop doesn't have refreshenv like Chocolatey + # Let's try to update PATH first + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + $scoop_app = Get-Command -Name scoop.ps1 -Type ExternalScript -ErrorAction SilentlyContinue + if ($module.CheckMode -and $null -eq $scoop_app) { + $module.Result.skipped = $true + $module.Result.msg = "Skipped check mode run on win_scoop as scoop.ps1 cannot be found on the system" + $module.ExitJson() + } + elseif ($null -eq $scoop_app) { + # We need to install scoop + # We run this in a separate process to make it easier to get the result in a failure and capture any output that + # might be sent to the host. We also need to enable TLS 1.2 in that process and not here so it can download the + # install script and other components. + $install_script = { + # Enable TLS1.2 if it's available but disabled (eg. .NET 4.5) + $security_protocols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::SystemDefault + if ([Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { + $security_protocols = $security_protcols -bor [Net.SecurityProtocolType]::Tls12 + } + [Net.ServicePointManager]::SecurityProtocol = $security_protocols + + $script = (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh') + $installer = [ScriptBlock]::Create($script) + $params = @{} + + $current_user = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if ($current_user.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $params.RunAsAdmin = $true + } + . $installer -RunAsAdmin + } + + $enc_command = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($install_script.ToString())) + $cmd = "powershell.exe -NoProfile -NoLogo -EncodedCommand $enc_command" + + # Scoops installer does weird things and the exit code will most likely be 0. Use the presence of the scoop + # command as the indicator as to whether it was installed or not. + $res = Run-Command -Command $cmd -environment $environment + + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + # locate the newly installed scoop.ps1 + $scoop_app = Get-Command -Name scoop.ps1 -Type ExternalScript -ErrorAction SilentlyContinue + + if ($null -eq $scoop_app -or -not (Test-Path -LiteralPath $scoop_app.Path)) { + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Failed to bootstrap scoop.ps1") + } + + $module.Warn("Scoop was missing from this system, so it was installed during this task run.") + $module.Result.changed = $true + } + + return $scoop_app.Path +} + +function Get-ScoopPackage { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path + ) + + $command = Argv-ToString -arguments @("powershell.exe", $scoop_path, "export") + $res = Run-Command -Command $command + if ($res.rc -ne 0) { + $module.Result.command = $command + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Error checking installed packages") + } + + # Scoop since 0.2.3 output as JSON, older versions use a custom format + # https://github.com/ScoopInstaller/Scoop/commit/c4d1b9c22f943a810bed7f9f74d7d4d5c42d9a74 + try { + $exportObj = ConvertFrom-Json -InputObject $res.stdout -ErrorAction Stop + } + catch { + $res.stdout -split "`n" | + Select-String '(.*?) \(v:(.*?)\)( \*global\*)? \[(.*?)\](\{32bit\})?' | + ForEach-Object { + [PSCustomObject]@{ + Package = $_.Matches[0].Groups[1].Value + Version = $_.Matches[0].Groups[2].Value + Global = -not ([string]::IsNullOrWhiteSpace($_.Matches[0].Groups[3].Value)) + Bucket = $_.Matches[0].Groups[4].Value + x86 = -not ([string]::IsNullOrWhiteSpace($_.Matches[0].Groups[5].Value)) + } + } + return + } + + $exportObj.apps | ForEach-Object { + $options = @($_.Info -split ',' | ForEach-Object Trim | Where-Object { $_ }) + if ('Install failed' -in $options) { + return + } + + [PSCustomObject]@{ + Package = $_.Name + Version = $_.Version + Global = 'Global install' -in $options + Bucket = $_.Source + x86 = '32bit' -in $options + } + } +} + +function Get-InstallScoopPackageArgument { + $arguments = [System.Collections.Generic.List[String]]@() + + if ($architecture) { + $arguments.Add("--arch") + $arguments.Add($architecture) + } + if ($global) { + $arguments.Add("--global") + } + if ($independent) { + $arguments.Add("--independent") + } + if ($no_cache) { + $arguments.Add("--no-cache") + } + if ($skip_checksum) { + $arguments.Add("--skip") + } + + return , $arguments +} + +function Install-ScoopPackage { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path, + [Parameter(Mandatory = $true)] [String[]]$packages + ) + $arguments = [System.Collections.Generic.List[String]]@("powershell.exe", $scoop_path, "install") + $arguments.AddRange($packages) + + $common_args = Get-InstallScoopPackageArgument + $arguments.AddRange($common_args) + + $command = Argv-ToString -arguments $arguments + + if (-not $module.CheckMode) { + $res = Run-Command -Command $command + if ($res.rc -ne 0) { + $module.Result.command = $command + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Error installing $packages") + } + + if ($module.Verbosity -gt 1) { + $module.Result.stdout = $res.stdout + } + } + $module.Result.changed = $true +} + +function Get-UninstallScoopPackageArgument { + $arguments = [System.Collections.Generic.List[String]]@() + + if ($global) { + $arguments.Add("--global") + } + if ($purge) { + $arguments.Add("--purge") + } + + return , $arguments +} + +function Uninstall-ScoopPackage { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path, + [Parameter(Mandatory = $true)] [String[]]$packages + ) + + $arguments = [System.Collections.Generic.List[String]]@("powershell.exe", $scoop_path, "uninstall") + $arguments.AddRange($packages) + + $common_args = Get-UninstallScoopPackageArgument + $arguments.AddRange($common_args) + + $command = Argv-ToString -arguments $arguments + + if (-not $module.CheckMode) { + $res = Run-Command -Command $command + if ($res.rc -ne 0) { + $module.Result.command = $command + $module.Result.rc = $res.rc + $module.Result.stdout = $res.stdout + $module.Result.stderr = $res.stderr + $module.FailJson("Error uninstalling $packages") + } + + if ($module.Verbosity -gt 1) { + $module.Result.stdout = $res.stdout + } + if (-not ($res.stdout -match "ERROR '(.*?)' isn't installed.")) { + $module.Result.changed = $true + } + } + else { + $module.Result.changed = $true + } +} + +$scoop_path = Install-Scoop + +$installed_packages = @(Get-ScoopPackage -scoop_path $scoop_path) + +if ($state -in @("absent")) { + # Always attempt uninstall + # Packages can be in a broken state where they don't in appear scoop export + Uninstall-ScoopPackage -scoop_path $scoop_path -packages $name +} + +if ($state -in @("present")) { + $missing_packages = foreach ($package in $name) { + if ( + ($installed_packages.Package -notcontains $package) -or + (($installed_packages.Package -contains $package) -and ( + ((($installed_packages | Where-Object { $_.Package -eq $package }).Global -contains $true) -and -not $global) -or + ((($installed_packages | Where-Object { $_.Package -eq $package }).Global -notcontains $true) -and $global) + ) + ) + ) { + $package + } + } +} + +if ($missing_packages) { + Install-ScoopPackage -scoop_path $scoop_path -packages $missing_packages +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_scoop.py b/ansible_collections/community/windows/plugins/modules/win_scoop.py new file mode 100644 index 00000000..288e75e2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scoop.py @@ -0,0 +1,79 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Jamie Magee <jamie.magee@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_scoop +short_description: Manage packages using Scoop +description: +- Manage packages using Scoop. +- If Scoop is missing from the system, the module will install it. +options: + architecture: + description: + - Force Scoop to install the package of a specific process architecture. + type: str + choices: [ 32bit, 64bit ] + aliases: [ arch ] + global: + description: + - Install the app globally + type: bool + default: no + independent: + description: + - Don't install dependencies automatically + type: bool + default: no + name: + description: + - Name of the package(s) to be installed. + type: list + elements: str + required: yes + no_cache: + description: + - Don't use the download cache + type: bool + default: no + purge: + description: + - Remove all persistent data + type: bool + default: no + skip_checksum: + description: + - Skip hash validation + type: bool + default: no + state: + description: + - State of the package on the system. + - When C(absent), will ensure the package is not installed. + - When C(present), will ensure the package is installed. + type: str + choices: [ absent, present ] + default: present +seealso: +- module: chocolatey.chocolatey.win_chocolatey +- name: Scoop website + description: More information about Scoop + link: https://scoop.sh +- name: Scoop installer repository + description: GitHub repository for the Scoop installer + link: https://github.com/lukesampson/scoop +- name: Scoop main bucket + description: GitHub repository for the main bucket + link: https://github.com/ScoopInstaller/Main +author: +- Jamie Magee (@JamieMagee) +''' + +EXAMPLES = r''' +- name: Install jq. + community.windows.win_scoop: + name: jq +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.ps1 b/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.ps1 new file mode 100644 index 00000000..70c02cb0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.ps1 @@ -0,0 +1,125 @@ +#!powershell + +# Copyright: (c) 2020, Jamie Magee <jamie.magee@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str"; required = $true } + repo = @{ type = "str" } + state = @{ type = "str"; default = "present"; choices = "present", "absent" } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$name = $module.Params.name +$repo = $module.Params.repo +$state = $module.Params.state + +# Kept for backwards compatibility +$module.Result.rc = 0 + +function Get-Scoop { + # Scoop doesn't have refreshenv like Chocolatey + # Let's try to update PATH first + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + $scoop_app = Get-Command -Name scoop.ps1 -Type ExternalScript -ErrorAction SilentlyContinue + + if ($module.CheckMode -and $null -eq $scoop_app) { + $module.Result.skipped = $true + $module.Result.msg = "Skipped check mode run on win_scoop_bucket as scoop.ps1 cannot be found on the system" + $module.ExitJson() + } + + if ($null -eq $scoop_app -or -not (Test-Path -LiteralPath $scoop_app.Path)) { + $module.FailJson("Failed to find scoop.ps1, make sure it is added to the PATH") + } + + return $scoop_app.Path +} + +function Get-ScoopBucket { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path + ) + + &$scoop_path bucket list +} + +function Uninstall-ScoopBucket { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path, + [Parameter(Mandatory = $true)] [String]$bucket + ) + + $arguments = @( + "bucket", + "rm" + $bucket + if ($repo) { $repo } + ) + if (-not $module.CheckMode) { + $res = (&$scoop_path @arguments) -join "`n" + if (-not $?) { + $module.Result.stdout = $res + $module.Result.rc = 1 + $module.FailJson("Failed to remove scoop bucket, see stdout for more details") + } + elseif ($module.Verbosity -gt 1) { + $module.Result.stdout = $res + } + } + + $module.Result.changed = $true +} + +function Install-ScoopBucket { + param( + [Parameter(Mandatory = $true)] [string]$scoop_path, + [Parameter(Mandatory = $true)] [String]$bucket, + [String]$repo + ) + + $arguments = @( + "bucket" + "add" + $bucket + if ($repo) { $repo } + ) + if (-not $module.CheckMode) { + $res = (&$scoop_path @arguments) -join "`n" + if (-not $?) { + $module.Result.stdout = $res + $module.Result.rc = 1 + $module.FailJson("Failed to add scoop bucket, see stdout for more details") + } + elseif ($module.Verbosity -gt 1) { + $module.Result.stdout = $res + } + } + + $module.Result.changed = $true +} + +$scoop_path = Get-Scoop + +$installed_buckets = Get-ScoopBucket -scoop_path $scoop_path + +if ($state -in @("absent")) { + if ($installed_buckets.Name -contains $name) { + Uninstall-ScoopBucket -scoop_path $scoop_path -bucket $name + } +} + +if ($state -in @("present")) { + if ($installed_buckets.Name -notcontains $name) { + Install-ScoopBucket -scoop_path $scoop_path -bucket $name -repo $repo + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.py b/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.py new file mode 100644 index 00000000..57bb9464 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_scoop_bucket.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Jamie Magee <jamie.magee@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_scoop_bucket +version_added: 1.0.0 +short_description: Manage Scoop buckets +description: +- Manage Scoop buckets +requirements: +- git +options: + name: + description: + - Name of the Scoop bucket. + type: str + required: yes + repo: + description: + - Git repository that contains the scoop bucket + type: str + state: + description: + - State of the Scoop bucket. + - When C(absent), will ensure the package is not installed. + - When C(present), will ensure the package is installed. + type: str + choices: [ absent, present ] + default: present +seealso: +- module: community.windows.win_scoop +- name: Scoop website + description: More information about Scoop + link: https://scoop.sh +- name: Scoop directory + description: A directory of buckets for the scoop package manager for Windows + link: https://rasa.github.io/scoop-directory/ +author: +- Jamie Magee (@JamieMagee) +''' + +EXAMPLES = r''' +- name: Add the extras bucket + community.windows.win_scoop_bucket: + name: extras + +- name: Remove the versions bucket + community.windows.win_scoop_bucket: + name: versions + state: absent + +- name: Add a custom bucket + community.windows.win_scoop_bucket: + name: my-bucket + repo: https://github.com/example/my-bucket +''' + +RETURN = r''' +rc: + description: The result code of the scoop action + returned: always + type: int + sample: 0 +stdout: + description: The raw output from the scoop action + returned: on failure or when verbosity is greater than 1 + type: str + sample: The test bucket was added successfully. +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_security_policy.ps1 b/ansible_collections/community/windows/plugins/modules/win_security_policy.ps1 new file mode 100644 index 00000000..dd7016fe --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_security_policy.ps1 @@ -0,0 +1,224 @@ +#!powershell + +# Copyright: (c) 2017, Jordan Borean <jborean93@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $Params -name "_ansible_diff" -type "bool" -default $false + +$section = Get-AnsibleParam -obj $params -name "section" -type "str" -failifempty $true +$key = Get-AnsibleParam -obj $params -name "key" -type "str" -failifempty $true +$value = Get-AnsibleParam -obj $params -name "value" -failifempty $true + +$result = @{ + changed = $false + section = $section + key = $key + value = $value +} + +if ($diff_mode) { + $result.diff = @{} +} + +Function Invoke-SecEdit($arguments) { + $stdout = $null + $stderr = $null + $log_path = [IO.Path]::GetTempFileName() + $arguments = $arguments + @("/log", $log_path) + + try { + $stdout = &SecEdit.exe $arguments | Out-String + } + catch { + $stderr = $_.Exception.Message + } + $log = Get-Content -LiteralPath $log_path + Remove-Item -LiteralPath $log_path -Force + + $return = @{ + log = ($log -join "`n").Trim() + stdout = $stdout + stderr = $stderr + rc = $LASTEXITCODE + } + + return $return +} + +Function Export-SecEdit() { + # GetTempFileName() will create a file but it doesn't have any content. This is problematic as secedit uses the + # encoding of the file at /cfg if it exists and because there is no BOM it will export using the "ANSI" encoding. + # By making sure the file exists and has a UTF-16-LE BOM we can be sure our parser reads the bytes as a string + # correctly. + $secedit_ini_path = [IO.Path]::GetTempFileName() + Set-Content -LiteralPath $secedit_ini_path -Value '' -Encoding Unicode + + # while this will technically make a change to the system in check mode by + # creating a new file, we need these values to be able to do anything + # substantial in check mode + $export_result = Invoke-SecEdit -arguments @("/export", "/cfg", $secedit_ini_path, "/quiet") + + # check the return code and if the file has been populated, otherwise error out + if (($export_result.rc -ne 0) -or ((Get-Item -LiteralPath $secedit_ini_path).Length -eq 0)) { + Remove-Item -LiteralPath $secedit_ini_path -Force + $result.rc = $export_result.rc + $result.stdout = $export_result.stdout + $result.stderr = $export_result.stderr + Fail-Json $result "Failed to export secedit.ini file to $($secedit_ini_path)" + } + $secedit_ini = ConvertFrom-Ini -file_path $secedit_ini_path + + return $secedit_ini +} + +Function Import-SecEdit($ini) { + $secedit_ini_path = [IO.Path]::GetTempFileName() + $secedit_db_path = [IO.Path]::GetTempFileName() + Remove-Item -LiteralPath $secedit_db_path -Force # needs to be deleted for SecEdit.exe /import to work + + $ini_contents = ConvertTo-Ini -ini $ini + + # Use Unicode (UTF-16-LE) as that is the same across all PowerShell versions and we don't have to worry about + # changing ANSI encodings. + Set-Content -LiteralPath $secedit_ini_path -Value $ini_contents -Encoding Unicode + $result.changed = $true + + $import_result = Invoke-SecEdit -arguments @("/configure", "/db", $secedit_db_path, "/cfg", $secedit_ini_path, "/quiet") + $result.import_log = $import_result.log + Remove-Item -LiteralPath $secedit_ini_path -Force + if ($import_result.rc -ne 0) { + $result.rc = $import_result.rc + $result.stdout = $import_result.stdout + $result.stderr = $import_result.stderr + Fail-Json $result "Failed to import secedit.ini file from $($secedit_ini_path)" + } + + # https://github.com/ansible-collections/community.windows/issues/153 + # The LegalNoticeText entry is stored in the ini with type 7 (REG_MULTI_SZ) where each comma entry is read as a + # newline. When secedit imports the value it sets LegalNoticeText in the registry to be a REG_SZ type with the + # newlines but it also adds the extra null char at the end that REG_MULTI_SZ uses to denote the end of an entry. + # We manually trim off that extra null char so the legal text does not contain the unknown character symbol. + $legalPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + $legalName = 'LegalNoticeText' + $prop = Get-ItemProperty -LiteralPath $legalPath + if ($legalName -in $prop.PSObject.Properties.Name) { + $existingText = $prop.LegalNoticeText.TrimEnd("`0") + Set-ItemProperty -LiteralPath $legalPath -Name $legalName -Value $existingText + } +} + +Function ConvertTo-Ini($ini) { + $content = @() + foreach ($key in $ini.GetEnumerator()) { + $section = $key.Name + $values = $key.Value + + $content += "[$section]" + foreach ($value in $values.GetEnumerator()) { + $value_key = $value.Name + $value_value = $value.Value + + if ($null -ne $value_value) { + $content += "$value_key = $value_value" + } + } + } + + return $content -join "`r`n" +} + +Function ConvertFrom-Ini($file_path) { + $ini = @{} + switch -Regex -File $file_path { + "^\[(.+)\]" { + $section = $matches[1] + $ini.$section = @{} + } + "(.+?)\s*=(.*)" { + $name = $matches[1].Trim() + $value = $matches[2].Trim() + if ($value -match "^\d+$") { + $value = [int]$value + } + elseif ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + + $ini.$section.$name = $value + } + } + + return $ini +} + +if ($section -eq "Privilege Rights") { + Add-Warning -obj $result -message "Using this module to edit rights and privileges is error-prone, use the ansible.windows.win_user_right module instead" +} + +$will_change = $false +$secedit_ini = Export-SecEdit +if (-not ($secedit_ini.ContainsKey($section))) { + Fail-Json $result "The section '$section' does not exist in SecEdit.exe output ini" +} + +if ($secedit_ini.$section.ContainsKey($key)) { + $current_value = $secedit_ini.$section.$key + + if ($current_value -cne $value) { + if ($diff_mode) { + $result.diff.prepared = @" +[$section] +-$key = $current_value ++$key = $value +"@ + } + + $secedit_ini.$section.$key = $value + $will_change = $true + } +} +elseif ([string]$value -eq "") { + # Value is requested to be removed, and has already been removed, do nothing +} +else { + if ($diff_mode) { + $result.diff.prepared = @" +[$section] ++$key = $value +"@ + } + $secedit_ini.$section.$key = $value + $will_change = $true +} + +if ($will_change -eq $true) { + $result.changed = $true + if (-not $check_mode) { + Import-SecEdit -ini $secedit_ini + + # secedit doesn't error out on improper entries, re-export and verify + # the changes occurred + $verification_ini = Export-SecEdit + $new_section_values = $verification_ini.$section + if ($new_section_values.ContainsKey($key)) { + $new_value = $new_section_values.$key + if ($new_value -cne $value) { + Fail-Json $result "Failed to change the value for key '$key' in section '$section', the value is still $new_value" + } + } + elseif ([string]$value -eq "") { + # Value was empty, so OK if no longer in the result + } + else { + Fail-Json $result "The key '$key' in section '$section' is not a valid key, cannot set this value" + } + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_security_policy.py b/ansible_collections/community/windows/plugins/modules/win_security_policy.py new file mode 100644 index 00000000..acc00f2d --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_security_policy.py @@ -0,0 +1,118 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_security_policy +short_description: Change local security policy settings +description: +- Allows you to set the local security policies that are configured by + SecEdit.exe. +options: + section: + description: + - The ini section the key exists in. + - If the section does not exist then the module will return an error. + - Example sections to use are 'Account Policies', 'Local Policies', + 'Event Log', 'Restricted Groups', 'System Services', 'Registry' and + 'File System' + - If wanting to edit the C(Privilege Rights) section, use the + M(ansible.windows.win_user_right) module instead. + type: str + required: yes + key: + description: + - The ini key of the section or policy name to modify. + - The module will return an error if this key is invalid. + type: str + required: yes + value: + description: + - The value for the ini key or policy name. + - If the key takes in a boolean value then 0 = False and 1 = True. + type: str + required: yes +notes: +- This module uses the SecEdit.exe tool to configure the values, more details + of the areas and keys that can be configured can be found here + U(https://msdn.microsoft.com/en-us/library/bb742512.aspx). +- If you are in a domain environment these policies may be set by a GPO policy, + this module can temporarily change these values but the GPO will override + it if the value differs. +- You can also run C(SecEdit.exe /export /cfg C:\temp\output.ini) to view the + current policies set on your system. +- When assigning user rights, use the M(ansible.windows.win_user_right) module instead. +seealso: +- module: ansible.windows.win_user_right +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Change the guest account name + community.windows.win_security_policy: + section: System Access + key: NewGuestName + value: Guest Account + +- name: Set the maximum password age + community.windows.win_security_policy: + section: System Access + key: MaximumPasswordAge + value: 15 + +- name: Do not store passwords using reversible encryption + community.windows.win_security_policy: + section: System Access + key: ClearTextPassword + value: 0 + +- name: Enable system events + community.windows.win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 1 +''' + +RETURN = r''' +rc: + description: The return code after a failure when running SecEdit.exe. + returned: failure with secedit calls + type: int + sample: -1 +stdout: + description: The output of the STDOUT buffer after a failure when running + SecEdit.exe. + returned: failure with secedit calls + type: str + sample: check log for error details +stderr: + description: The output of the STDERR buffer after a failure when running + SecEdit.exe. + returned: failure with secedit calls + type: str + sample: failed to import security policy +import_log: + description: The log of the SecEdit.exe /configure job that configured the + local policies. This is used for debugging purposes on failures. + returned: secedit.exe /import run and change occurred + type: str + sample: Completed 6 percent (0/15) \tProcess Privilege Rights area. +key: + description: The key in the section passed to the module to modify. + returned: success + type: str + sample: NewGuestName +section: + description: The section passed to the module to modify. + returned: success + type: str + sample: System Access +value: + description: The value passed to the module to modify to. + returned: success + type: str + sample: Guest Account +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_shortcut.ps1 b/ansible_collections/community/windows/plugins/modules/win_shortcut.ps1 new file mode 100644 index 00000000..1b0ab650 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_shortcut.ps1 @@ -0,0 +1,381 @@ +#!powershell + +# Copyright: (c) 2016, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Based on: http://powershellblogger.com/2016/01/create-shortcuts-lnk-or-url-files-with-powershell/ + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + src = @{ type = 'str' } + dest = @{ type = 'path'; required = $true } + state = @{ type = 'str'; default = 'present'; choices = @( 'absent', 'present' ) } + arguments = @{ type = 'str'; aliases = @( 'args' ) } + directory = @{ type = 'path' } + hotkey = @{ type = 'str'; no_log = $false } + icon = @{ type = 'path' } + description = @{ type = 'str' } + windowstyle = @{ type = 'str'; choices = @( 'maximized', 'minimized', 'normal' ) } + run_as_admin = @{ type = 'bool'; default = $false } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$src = $module.Params.src +$dest = $module.Params.dest +$state = $module.Params.state +$arguments = $module.Params.arguments # NOTE: Variable $args is a special variable +$directory = $module.Params.directory +$hotkey = $module.Params.hotkey +$icon = $module.Params.icon +$description = $module.Params.description +$windowstyle = $module.Params.windowstyle +$run_as_admin = $module.Params.run_as_admin + +# Expand environment variables on non-path types +if ($null -ne $src) { + $src = [System.Environment]::ExpandEnvironmentVariables($src) +} +if ($null -ne $arguments) { + $arguments = [System.Environment]::ExpandEnvironmentVariables($arguments) +} +if ($null -ne $description) { + $description = [System.Environment]::ExpandEnvironmentVariables($description) +} + +$module.Result.changed = $false +$module.Result.dest = $dest +$module.Result.state = $state + +# TODO: look at consolidating other COM actions into the C# class for future compatibility +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; + +namespace Ansible.Shortcut +{ + [ComImport()] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + internal interface IShellLinkW + { + // We only care about GetPath and GetIDList, omit the other methods for now + void GetPath(StringBuilder pszFile, int cch, IntPtr pfd, UInt32 fFlags); + void GetIDList(out IntPtr ppidl); + } + + [ComImport()] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("45E2b4AE-B1C3-11D0-B92F-00A0C90312E1")] + internal interface IShellLinkDataList + { + void AddDataBlock(IntPtr pDataBlock); + void CopyDataBlock(uint dwSig, out IntPtr ppDataBlock); + void RemoveDataBlock(uint dwSig); + void GetFlags(out ShellLinkFlags dwFlags); + void SetFlags(ShellLinkFlags dwFlags); + } + + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SHFILEINFO + { + public IntPtr hIcon; + public int iIcon; + public UInt32 dwAttributes; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 260)] public char[] szDisplayName; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)] public char[] szTypeName; + } + } + + internal class NativeMethods + { + [DllImport("shell32.dll")] + public static extern void ILFree( + IntPtr pidl); + + [DllImport("shell32.dll")] + public static extern IntPtr SHGetFileInfoW( + IntPtr pszPath, + UInt32 dwFileAttributes, + ref NativeHelpers.SHFILEINFO psfi, + int sbFileInfo, + UInt32 uFlags); + + [DllImport("shell32.dll")] + public static extern int SHParseDisplayName( + [MarshalAs(UnmanagedType.LPWStr)] string pszName, + IntPtr pbc, + out IntPtr ppidl, + UInt32 sfagoIn, + out UInt32 psfgaoOut); + } + + [System.Flags] + public enum ShellLinkFlags : uint + { + Default = 0x00000000, + HasIdList = 0x00000001, + HasLinkInfo = 0x00000002, + HasName = 0x00000004, + HasRelPath = 0x00000008, + HasWorkingDir = 0x00000010, + HasArgs = 0x00000020, + HasIconLocation = 0x00000040, + Unicode = 0x00000080, + ForceNoLinkInfo = 0x00000100, + HasExpSz = 0x00000200, + RunInSeparate = 0x00000400, + HasLogo3Id = 0x00000800, + HasDarwinId = 0x00001000, + RunAsUser = 0x00002000, + HasExpIconSz = 0x00004000, + NoPidlAlias = 0x00008000, + ForceUncName = 0x00010000, + RunWithShimLayer = 0x00020000, + ForceNoLinkTrack = 0x00040000, + EnableTargetMetadata = 0x00080000, + DisableLinkPathTracking = 0x00100000, + DisableKnownFolderRelativeTracking = 0x00200000, + NoKfAlias = 0x00400000, + AllowLinkToLink = 0x00800000, + UnAliasOnSave = 0x01000000, + PreferEnvironmentPath = 0x02000000, + KeepLocalIdListForUncTarget = 0x04000000, + PersistVolumeIdToRelative = 0x08000000, + Valid = 0x0FFFF7FF, + Reserved = 0x80000000 + } + + public class ShellLink + { + private static Guid CLSID_ShellLink = new Guid("00021401-0000-0000-C000-000000000046"); + + public static ShellLinkFlags GetFlags(string path) + { + IShellLinkW link = InitialiseObj(path); + ShellLinkFlags dwFlags; + ((IShellLinkDataList)link).GetFlags(out dwFlags); + return dwFlags; + } + + public static void SetFlags(string path, ShellLinkFlags flags) + { + IShellLinkW link = InitialiseObj(path); + ((IShellLinkDataList)link).SetFlags(flags); + ((IPersistFile)link).Save(null, false); + } + + public static string GetTargetPath(string path) + { + IShellLinkW link = InitialiseObj(path); + + StringBuilder pathSb = new StringBuilder(260); + link.GetPath(pathSb, pathSb.Capacity, IntPtr.Zero, 0); + string linkPath = pathSb.ToString(); + + // If the path wasn't set, try and get the path from the ItemIDList + ShellLinkFlags flags = GetFlags(path); + if (String.IsNullOrEmpty(linkPath) && ((uint)flags & (uint)ShellLinkFlags.HasIdList) == (uint)ShellLinkFlags.HasIdList) + { + IntPtr idList = IntPtr.Zero; + try + { + link.GetIDList(out idList); + linkPath = GetDisplayNameFromPidl(idList); + } + finally + { + NativeMethods.ILFree(idList); + } + } + return linkPath; + } + + public static string GetDisplayNameFromPath(string path) + { + UInt32 sfgaoOut; + IntPtr pidl = IntPtr.Zero; + try + { + int res = NativeMethods.SHParseDisplayName(path, IntPtr.Zero, out pidl, 0, out sfgaoOut); + Marshal.ThrowExceptionForHR(res); + return GetDisplayNameFromPidl(pidl); + } + finally + { + NativeMethods.ILFree(pidl); + } + } + + private static string GetDisplayNameFromPidl(IntPtr pidl) + { + NativeHelpers.SHFILEINFO shFileInfo = new NativeHelpers.SHFILEINFO(); + UInt32 uFlags = 0x000000208; // SHGFI_DISPLAYNAME | SHGFI_PIDL + NativeMethods.SHGetFileInfoW(pidl, 0, ref shFileInfo, Marshal.SizeOf(typeof(NativeHelpers.SHFILEINFO)), uFlags); + return new string(shFileInfo.szDisplayName).TrimEnd('\0'); + } + + private static IShellLinkW InitialiseObj(string path) + { + IShellLinkW link = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_ShellLink)) as IShellLinkW; + ((IPersistFile)link).Load(path, 0); + return link; + } + } +} +'@ + +# Convert from window style name to window style id +$windowstyles = @{ + normal = 1 + maximized = 3 + minimized = 7 +} + +# Convert from window style id to window style name +$windowstyleids = @( "", "normal", "", "maximized", "", "", "", "minimized" ) + +If ($state -eq "absent") { + If (Test-Path -LiteralPath $dest) { + # If the shortcut exists, try to remove it + Try { + Remove-Item -LiteralPath $dest -WhatIf:$module.CheckMode + } + Catch { + # Report removal failure + $module.FailJson("Failed to remove shortcut '$dest'. ($($_.Exception.Message))", $_) + } + # Report removal success + $module.Result.changed = $true + } + Else { + # Nothing to report, everything is fine already + } +} +ElseIf ($state -eq "present") { + # Create an in-memory object based on the existing shortcut (if any) + $Shell = New-Object -ComObject ("WScript.Shell") + $ShortCut = $Shell.CreateShortcut($dest) + + # Compare existing values with new values, report as changed if required + + If ($null -ne $src) { + # Windows translates executables to absolute path, so do we + If (Get-Command -Name $src -Type Application -ErrorAction SilentlyContinue) { + $src = (Get-Command -Name $src -Type Application).Definition + } + If (-not (Test-Path -LiteralPath $src -IsValid)) { + If (-not (Split-Path -Path $src -IsAbsolute)) { + $module.FailJson("Source '$src' is not found in PATH and not a valid or absolute path.") + } + } + } + + # Determine if we have a WshShortcut or WshUrlShortcut by checking the Arguments property + # A WshUrlShortcut objects only consists of a TargetPath property + + $file_shortcut = $false + If (Get-Member -InputObject $ShortCut -Name Arguments) { + # File ShortCut, compare multiple properties + $file_shortcut = $true + + $target_path = $ShortCut.TargetPath + If (($null -ne $src) -and ($ShortCut.TargetPath -ne $src)) { + if ((Test-Path -LiteralPath $dest) -and (-not $ShortCut.TargetPath)) { + # If the shortcut already exists but not on the COM object, we + # are dealing with a shell path like 'shell:RecycleBinFolder'. + $expanded_src = [Ansible.Shortcut.ShellLink]::GetDisplayNameFromPath($src) + $actual_src = [Ansible.Shortcut.ShellLink]::GetTargetPath($dest) + if ($expanded_src -ne $actual_src) { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + } + else { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + $target_path = $src + } + + # This is a full-featured application shortcut ! + If (($null -ne $arguments) -and ($ShortCut.Arguments -ne $arguments)) { + $module.Result.changed = $true + $ShortCut.Arguments = $arguments + } + $module.Result.args = $ShortCut.Arguments + + If (($null -ne $directory) -and ($ShortCut.WorkingDirectory -ne $directory)) { + $module.Result.changed = $true + $ShortCut.WorkingDirectory = $directory + } + $module.Result.directory = $ShortCut.WorkingDirectory + + # FIXME: Not all values are accepted here ! Improve docs too. + If (($null -ne $hotkey) -and ($ShortCut.Hotkey -ne $hotkey)) { + $module.Result.changed = $true + $ShortCut.Hotkey = $hotkey + } + $module.Result.hotkey = $ShortCut.Hotkey + + If (($null -ne $icon) -and ($ShortCut.IconLocation -ne $icon)) { + $module.Result.changed = $true + $ShortCut.IconLocation = $icon + } + $module.Result.icon = $ShortCut.IconLocation + + If (($null -ne $description) -and ($ShortCut.Description -ne $description)) { + $module.Result.changed = $true + $ShortCut.Description = $description + } + $module.Result.description = $ShortCut.Description + + If (($null -ne $windowstyle) -and ($ShortCut.WindowStyle -ne $windowstyles.$windowstyle)) { + $module.Result.changed = $true + $ShortCut.WindowStyle = $windowstyles.$windowstyle + } + $module.Result.windowstyle = $windowstyleids[$ShortCut.WindowStyle] + } + else { + # URL Shortcut, just compare the TargetPath + if (($null -ne $src) -and ($ShortCut.TargetPath -ne $src)) { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + $target_path = $ShortCut.TargetPath + } + $module.Result.src = $target_path + + If (($module.Result.changed -eq $true) -and ($module.CheckMode -ne $true)) { + Try { + $ShortCut.Save() + } + Catch { + $module.FailJson("Failed to create shortcut '$dest'. ($($_.Exception.Message))", $_) + } + } + + if ((Test-Path -LiteralPath $dest) -and $file_shortcut) { + # Only control the run_as_admin flag if using a File Shortcut + $flags = [Ansible.Shortcut.ShellLink]::GetFlags($dest) + if ($run_as_admin -and (-not $flags.HasFlag([Ansible.Shortcut.ShellLinkFlags]::RunAsUser))) { + [Ansible.Shortcut.ShellLink]::SetFlags($dest, ($flags -bor [Ansible.Shortcut.ShellLinkFlags]::RunAsUser)) + $module.Result.changed = $true + } + elseif (-not $run_as_admin -and ($flags.HasFlag([Ansible.Shortcut.ShellLinkFlags]::RunAsUser))) { + [Ansible.Shortcut.ShellLink]::SetFlags($dest, ($flags -bxor [Ansible.Shortcut.ShellLinkFlags]::RunAsUser)) + $module.Result.changed = $true + } + } +} + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_shortcut.py b/ansible_collections/community/windows/plugins/modules/win_shortcut.py new file mode 100644 index 00000000..8fcc55fd --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_shortcut.py @@ -0,0 +1,116 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_shortcut +short_description: Manage shortcuts on Windows +description: +- Create, manage and delete Windows shortcuts +options: + src: + description: + - Executable or URL the shortcut points to. + - The executable needs to be in your PATH, or has to be an absolute + path to the executable. + type: str + description: + description: + - Description for the shortcut. + - This is usually shown when hoovering the icon. + type: str + dest: + description: + - Destination file for the shortcuting file. + - File name should have a C(.lnk) or C(.url) extension. + type: path + required: yes + arguments: + description: + - Additional arguments for the executable defined in C(src). + type: str + aliases: [ args ] + directory: + description: + - Working directory for executable defined in C(src). + type: path + icon: + description: + - Icon used for the shortcut. + - File name should have a C(.ico) extension. + - The file name is followed by a comma and the number in the library file (.dll) or use 0 for an image file. + type: path + hotkey: + description: + - Key combination for the shortcut. + - This is a combination of one or more modifiers and a key. + - Possible modifiers are Alt, Ctrl, Shift, Ext. + - Possible keys are [A-Z] and [0-9]. + type: str + windowstyle: + description: + - Influences how the application is displayed when it is launched. + type: str + choices: [ maximized, minimized, normal ] + state: + description: + - When C(absent), removes the shortcut if it exists. + - When C(present), creates or updates the shortcut. + type: str + choices: [ absent, present ] + default: present + run_as_admin: + description: + - When C(src) is an executable, this can control whether the shortcut will be opened as an administrator or not. + type: bool + default: no +notes: +- 'The following options can include Windows environment variables: C(dest), C(args), C(description), C(dest), C(directory), C(icon) C(src)' +- 'Windows has two types of shortcuts: Application and URL shortcuts. URL shortcuts only consists of C(dest) and C(src)' +seealso: +- module: ansible.windows.win_file +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Create an application shortcut on the desktop + community.windows.win_shortcut: + src: C:\Program Files\Mozilla Firefox\Firefox.exe + dest: C:\Users\Public\Desktop\Mozilla Firefox.lnk + icon: C:\Program Files\Mozilla Firefox\Firefox.exe,0 + +- name: Create the same shortcut using environment variables + community.windows.win_shortcut: + description: The Mozilla Firefox web browser + src: '%ProgramFiles%\Mozilla Firefox\Firefox.exe' + dest: '%Public%\Desktop\Mozilla Firefox.lnk' + icon: '%ProgramFiles\Mozilla Firefox\Firefox.exe,0' + directory: '%ProgramFiles%\Mozilla Firefox' + hotkey: Ctrl+Alt+F + +- name: Create an application shortcut for an executable in PATH to your desktop + community.windows.win_shortcut: + src: cmd.exe + dest: Desktop\Command prompt.lnk + +- name: Create an application shortcut for the Ansible website + community.windows.win_shortcut: + src: '%ProgramFiles%\Google\Chrome\Application\chrome.exe' + dest: '%UserProfile%\Desktop\Ansible website.lnk' + arguments: --new-window https://ansible.com/ + directory: '%ProgramFiles%\Google\Chrome\Application' + icon: '%ProgramFiles%\Google\Chrome\Application\chrome.exe,0' + hotkey: Ctrl+Alt+A + +- name: Create a URL shortcut for the Ansible website + community.windows.win_shortcut: + src: https://ansible.com/ + dest: '%Public%\Desktop\Ansible website.url' +''' + +RETURN = r''' +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_snmp.ps1 b/ansible_collections/community/windows/plugins/modules/win_snmp.ps1 new file mode 100644 index 00000000..7e4b9af2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_snmp.ps1 @@ -0,0 +1,132 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$managers = Get-AnsibleParam -obj $params -name "permitted_managers" -type "list" -default $null +$communities = Get-AnsibleParam -obj $params -name "community_strings" -type "list" -default $null +$action_in = Get-AnsibleParam -obj $params -name "action" -type "str" -default "set" -ValidateSet @("set", "add", "remove") +$action = $action_in.ToLower() + +$result = @{ + failed = $False + changed = $False + community_strings = [System.Collections.ArrayList]@() + permitted_managers = [System.Collections.ArrayList]@() +} + +# Make sure lists are modifyable +[System.Collections.ArrayList]$managers = $managers +[System.Collections.ArrayList]$communities = $communities +[System.Collections.ArrayList]$indexes = @() + +# Type checking +# You would think that "$null -ne $managers" would work, but it doesn't. +# A proper type check is required. If a user provides an empty list then $managers +# is still of the correct type. If a user provides no option then $managers is $null. +If ($managers -Is [System.Collections.ArrayList] -And $managers.Count -gt 0 -And $managers[0] -IsNot [String]) { + Fail-Json $result "Permitted managers must be an array of strings" +} + +If ($communities -Is [System.Collections.ArrayList] -And $communities.Count -gt 0 -And $communities[0] -IsNot [String]) { + Fail-Json $result "SNMP communities must be an array of strings" +} + +$Managers_reg_key = "HKLM:\System\CurrentControlSet\services\SNMP\Parameters\PermittedManagers" +$Communities_reg_key = "HKLM:\System\CurrentControlSet\services\SNMP\Parameters\ValidCommunities" + +ForEach ($idx in (Get-Item -LiteralPath $Managers_reg_key).Property) { + $manager = (Get-ItemProperty -LiteralPath $Managers_reg_key).$idx + If ($idx.ToLower() -eq '(default)') { + continue + } + + $remove = $False + If ($managers -Is [System.Collections.ArrayList] -And $managers.Contains($manager)) { + If ($action -eq "remove") { + $remove = $True + } + Else { + # Remove manager from list to add since it already exists + $managers.Remove($manager) + } + } + ElseIf ($action -eq "set" -And $managers -Is [System.Collections.ArrayList]) { + # Will remove this manager since it is not in the set list + $remove = $True + } + + If ($remove) { + $result.changed = $True + Remove-ItemProperty -LiteralPath $Managers_reg_key -Name $idx -WhatIf:$check_mode + } + Else { + # Remember that this index is in use + $indexes.Add([int]$idx) | Out-Null + $result.permitted_managers.Add($manager) | Out-Null + } +} + +ForEach ($community in (Get-Item -LiteralPath $Communities_reg_key).Property) { + If ($community.ToLower() -eq '(default)') { + continue + } + + $remove = $False + If ($communities -Is [System.Collections.ArrayList] -And $communities.Contains($community)) { + If ($action -eq "remove") { + $remove = $True + } + Else { + # Remove community from list to add since it already exists + $communities.Remove($community) + } + } + ElseIf ($action -eq "set" -And $communities -Is [System.Collections.ArrayList]) { + # Will remove this community since it is not in the set list + $remove = $True + } + + If ($remove) { + $result.changed = $True + Remove-ItemProperty -LiteralPath $Communities_reg_key -Name $community -WhatIf:$check_mode + } + Else { + $result.community_strings.Add($community) | Out-Null + } +} + +If ($action -eq "remove") { + Exit-Json $result +} + +# Add managers that don't already exist +$next_index = 0 +If ($managers -Is [System.Collections.ArrayList]) { + ForEach ($manager in $managers) { + While ($True) { + $next_index = $next_index + 1 + If (-Not $indexes.Contains($next_index)) { + $result.changed = $True + New-ItemProperty -LiteralPath $Managers_reg_key -Name $next_index -Value "$manager" -WhatIf:$check_mode | Out-Null + $result.permitted_managers.Add($manager) | Out-Null + break + } + } + } +} + +# Add communities that don't already exist +If ($communities -Is [System.Collections.ArrayList]) { + ForEach ($community in $communities) { + $result.changed = $True + New-ItemProperty -LiteralPath $Communities_reg_key -Name $community -PropertyType DWord -Value 4 -WhatIf:$check_mode | Out-Null + $result.community_strings.Add($community) | Out-Null + } +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_snmp.py b/ansible_collections/community/windows/plugins/modules/win_snmp.py new file mode 100644 index 00000000..0068e214 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_snmp.py @@ -0,0 +1,70 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_snmp +short_description: Configures the Windows SNMP service +description: + - This module configures the Windows SNMP service. +options: + permitted_managers: + description: + - The list of permitted SNMP managers. + type: list + elements: str + community_strings: + description: + - The list of read-only SNMP community strings. + type: list + elements: str + action: + description: + - C(add) will add new SNMP community strings and/or SNMP managers + - C(set) will replace SNMP community strings and/or SNMP managers. An + empty list for either C(community_strings) or C(permitted_managers) + will result in the respective lists being removed entirely. + - C(remove) will remove SNMP community strings and/or SNMP managers + type: str + choices: [ add, set, remove ] + default: set +author: + - Michael Cassaniti (@mcassaniti) +''' + +EXAMPLES = r''' +- name: Replace SNMP communities and managers + community.windows.win_snmp: + community_strings: + - public + permitted_managers: + - 192.168.1.2 + action: set + +- name: Replace SNMP communities and clear managers + community.windows.win_snmp: + community_strings: + - public + permitted_managers: [] + action: set +''' + +RETURN = r''' +community_strings: + description: The list of community strings for this machine. + type: list + returned: always + sample: + - public + - snmp-ro +permitted_managers: + description: The list of permitted managers for this machine. + type: list + returned: always + sample: + - 192.168.1.1 + - 192.168.1.2 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_timezone.ps1 b/ansible_collections/community/windows/plugins/modules/win_timezone.ps1 new file mode 100644 index 00000000..c2f3b4a9 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_timezone.ps1 @@ -0,0 +1,73 @@ +#!powershell + +# Copyright: (c) 2015, Phil Schwartz <schwartzmx@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +$timezone = Get-AnsibleParam -obj $params -name "timezone" -type "str" -failifempty $true + +$result = @{ + changed = $false + previous_timezone = $timezone + timezone = $timezone +} + +Try { + # Get the current timezone set + $result.previous_timezone = $(tzutil.exe /g) + If ($LASTEXITCODE -ne 0) { + Throw "An error occurred when getting the current machine's timezone setting." + } + + if ( $result.previous_timezone -eq $timezone ) { + Exit-Json $result "Timezone '$timezone' is already set on this machine" + } + Else { + # Check that timezone is listed as an available timezone to the machine + $tzList = $(tzutil.exe /l).ToLower() + If ($LASTEXITCODE -ne 0) { + Throw "An error occurred when listing the available timezones." + } + + $tzExists = $tzList.Contains(($timezone -Replace '_dstoff').ToLower()) + if (-not $tzExists) { + Fail-Json $result "The specified timezone: $timezone isn't supported on the machine." + } + + if ($check_mode) { + $result.changed = $true + } + else { + tzutil.exe /s "$timezone" + if ($LASTEXITCODE -ne 0) { + Throw "An error occurred when setting the specified timezone with tzutil." + } + + $new_timezone = $(tzutil.exe /g) + if ($LASTEXITCODE -ne 0) { + Throw "An error occurred when getting the current machine's timezone setting." + } + + if ($timezone -eq $new_timezone) { + $result.changed = $true + } + } + + if ($diff_support) { + $result.diff = @{ + before = "$($result.previous_timezone)`n" + after = "$timezone`n" + } + } + } +} +Catch { + Fail-Json $result "Error setting timezone to: $timezone." +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_timezone.py b/ansible_collections/community/windows/plugins/modules/win_timezone.py new file mode 100644 index 00000000..d7f6adba --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_timezone.py @@ -0,0 +1,63 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Phil Schwartz <schwartzmx@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_timezone +short_description: Sets Windows machine timezone +description: +- Sets machine time to the specified timezone. +options: + timezone: + description: + - Timezone to set to. + - 'Example: Central Standard Time' + - To disable Daylight Saving time, add the suffix C(_dstoff) on timezones that support this. + type: str + required: yes +notes: +- The module will check if the provided timezone is supported on the machine. +- A list of possible timezones is available from C(tzutil.exe /l) and from + U(https://msdn.microsoft.com/en-us/library/ms912391.aspx) +- If running on Server 2008 the hotfix + U(https://support.microsoft.com/en-us/help/2556308/tzutil-command-line-tool-is-added-to-windows-vista-and-to-windows-server-2008) + needs to be installed to be able to run this module. +seealso: +- module: community.windows.win_region +author: +- Phil Schwartz (@schwartzmx) +''' + +EXAMPLES = r''' +- name: Set timezone to 'Romance Standard Time' (GMT+01:00) + community.windows.win_timezone: + timezone: Romance Standard Time + +- name: Set timezone to 'GMT Standard Time' (GMT) + community.windows.win_timezone: + timezone: GMT Standard Time + +- name: Set timezone to 'Central Standard Time' (GMT-06:00) + community.windows.win_timezone: + timezone: Central Standard Time + +- name: Set timezime to Pacific Standard time and disable Daylight Saving time adjustments + community.windows.win_timezone: + timezone: Pacific Standard Time_dstoff +''' + +RETURN = r''' +previous_timezone: + description: The previous timezone if it was changed, otherwise the existing timezone. + returned: success + type: str + sample: Central Standard Time +timezone: + description: The current timezone (possibly changed). + returned: success + type: str + sample: Central Standard Time +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_toast.ps1 b/ansible_collections/community/windows/plugins/modules/win_toast.ps1 new file mode 100644 index 00000000..5cb1cf26 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_toast.ps1 @@ -0,0 +1,93 @@ +#!powershell + +# Copyright: (c) 2017, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +# version check +$osversion = [Environment]::OSVersion +$lowest_version = 10 +if ($osversion.Version.Major -lt $lowest_version ) { + $msg = "Sorry, this version of windows, $osversion, does not support Toast notifications. Toast notifications are available from version $lowest_version" + Fail-Json -obj $result -message $msg +} + +$stopwatch = [system.diagnostics.stopwatch]::startNew() +$now = [DateTime]::Now +$default_title = "Notification: " + $now.ToShortTimeString() + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$expire_seconds = Get-AnsibleParam -obj $params -name "expire" -type "int" -default 45 +$group = Get-AnsibleParam -obj $params -name "group" -type "str" -default "Powershell" +$msg = Get-AnsibleParam -obj $params -name "msg" -type "str" -default "Hello world!" +$popup = Get-AnsibleParam -obj $params -name "popup" -type "bool" -default $true +$tag = Get-AnsibleParam -obj $params -name "tag" -type "str" -default "Ansible" +$title = Get-AnsibleParam -obj $params -name "title" -type "str" -default $default_title + +$timespan = New-TimeSpan -Seconds $expire_seconds +$expire_at = $now + $timespan +$expire_at_utc = $($expire_at.ToUniversalTime() | Out-String).Trim() + +$result = @{ + changed = $false + expire_at = $expire_at.ToString() + expire_at_utc = $expire_at_utc + toast_sent = $false +} + +# If no logged in users, there is no notifications service, +# and no-one to read the message, so exit but do not fail +# if there are no logged in users to notify. + +if ((Get-Process -Name explorer -ErrorAction SilentlyContinue).Count -gt 0) { + + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null + $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText01) + + #Convert to .NET type for XML manipulation + $toastXml = [xml] $template.GetXml() + $toastXml.GetElementsByTagName("text").AppendChild($toastXml.CreateTextNode($title)) > $null + # TODO add subtitle + + #Convert back to WinRT type + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml($toastXml.OuterXml) + + $toast = [Windows.UI.Notifications.ToastNotification]::new($xml) + $toast.Tag = $tag + $toast.Group = $group + $toast.ExpirationTime = $expire_at + $toast.SuppressPopup = -not $popup + + try { + $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($msg) + if (-not $check_mode) { + $notifier.Show($toast) + $result.toast_sent = $true + Start-Sleep -Seconds $expire_seconds + } + } + catch { + $excep = $_ + $result.exception = $excep.ScriptStackTrace + Fail-Json -obj $result -message "Failed to create toast notifier: $($excep.Exception.Message)" + } +} +else { + $result.toast_sent = $false + $result.no_toast_sent_reason = 'No logged in users to notify' +} + +$endsend_at = Get-Date | Out-String +$stopwatch.Stop() + +$result.time_taken = $stopwatch.Elapsed.TotalSeconds +$result.sent_localtime = $endsend_at.Trim() + +Exit-Json -obj $result diff --git a/ansible_collections/community/windows/plugins/modules/win_toast.py b/ansible_collections/community/windows/plugins/modules/win_toast.py new file mode 100644 index 00000000..e3fc1a07 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_toast.py @@ -0,0 +1,94 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Jon Hawkesworth (@jhawkesworth) <jhawkesworth@protonmail.com> +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_toast +short_description: Sends Toast windows notification to logged in users on Windows 10 or later hosts +description: + - Sends alerts which appear in the Action Center area of the windows desktop. +options: + expire: + description: + - How long in seconds before the notification expires. + type: int + default: 45 + group: + description: + - Which notification group to add the notification to. + type: str + default: Powershell + msg: + description: + - The message to appear inside the notification. + - May include \n to format the message to appear within the Action Center. + type: str + default: Hello, World! + popup: + description: + - If C(no), the notification will not pop up and will only appear in the Action Center. + type: bool + default: yes + tag: + description: + - The tag to add to the notification. + type: str + default: Ansible + title: + description: + - The notification title, which appears in the pop up.. + type: str + default: Notification HH:mm +notes: + - This module must run on a windows 10 or Server 2016 host, so ensure your play targets windows hosts, or delegates to a windows host. + - The module does not fail if there are no logged in users to notify. + - Messages are only sent to the local host where the module is run. + - You must run this module with async, otherwise it will hang until the expire period has passed. +seealso: +- module: community.windows.win_msg +- module: community.windows.win_say +author: +- Jon Hawkesworth (@jhawkesworth) +''' + +EXAMPLES = r''' +- name: Warn logged in users of impending upgrade (note use of async to stop the module from waiting until notification expires). + community.windows.win_toast: + expire: 60 + title: System Upgrade Notification + msg: Automated upgrade about to start. Please save your work and log off before {{ deployment_start_time }} + async: 60 + poll: 0 +''' + +RETURN = r''' +expire_at_utc: + description: Calculated utc date time when the notification expires. + returned: always + type: str + sample: 07 July 2017 04:50:54 +no_toast_sent_reason: + description: Text containing the reason why a notification was not sent. + returned: when no logged in users are detected + type: str + sample: No logged in users to notify +sent_localtime: + description: local date time when the notification was sent. + returned: always + type: str + sample: 07 July 2017 05:45:54 +time_taken: + description: How long the module took to run on the remote windows host in seconds. + returned: always + type: float + sample: 0.3706631999999997 +toast_sent: + description: Whether the module was able to send a toast notification or not. + returned: always + type: bool + sample: false +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_unzip.ps1 b/ansible_collections/community/windows/plugins/modules/win_unzip.ps1 new file mode 100644 index 00000000..bd2133e2 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_unzip.ps1 @@ -0,0 +1,197 @@ +#!powershell + +# Copyright: (c) 2015, Phil Schwartz <schwartzmx@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +# TODO: This module is not idempotent (it will always unzip and report change) + +$ErrorActionPreference = "Stop" + +$pcx_extensions = @('.bz2', '.gz', '.msu', '.tar', '.zip') + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$src = Get-AnsibleParam -obj $params -name "src" -type "path" -failifempty $true +$dest = Get-AnsibleParam -obj $params -name "dest" -type "path" -failifempty $true +$creates = Get-AnsibleParam -obj $params -name "creates" -type "path" +$recurse = Get-AnsibleParam -obj $params -name "recurse" -type "bool" -default $false +$delete_archive = Get-AnsibleParam -obj $params -name "delete_archive" -type "bool" -default $false -aliases 'rm' +$password = Get-AnsibleParam -obj $params -name "password" -type "str" + +# Fixes a fail error message (when the task actually succeeds) for a +# "Convert-ToJson: The converted JSON string is in bad format" +# This happens when JSON is parsing a string that ends with a "\", +# which is possible when specifying a directory to download to. +# This catches that possible error, before assigning the JSON $result +$result = @{ + changed = $false + dest = $dest -replace '\$', '' + removed = $false + src = $src -replace '\$', '' +} + +Function Expand-Zip($src, $dest) { + $archive = [System.IO.Compression.ZipFile]::Open($src, [System.IO.Compression.ZipArchiveMode]::Read, [System.Text.Encoding]::UTF8) + foreach ($entry in $archive.Entries) { + $archive_name = $entry.FullName + + $entry_target_path = [System.IO.Path]::Combine($dest, $archive_name) + $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) + + # Normalize paths for further evaluation + $full_target_path = [System.IO.Path]::GetFullPath($entry_target_path) + $full_dest_path = [System.IO.Path]::GetFullPath($dest + [System.IO.Path]::DirectorySeparatorChar) + + # Ensure file in the archive does not escape the extraction path + if (-not $full_target_path.StartsWith($full_dest_path)) { + $msg = "Error unzipping '$src' to '$dest'! Filename contains relative paths which would extract outside the destination: $entry_target_path" + Fail-Json -obj $result -message $msg + } + + if (-not (Test-Path -LiteralPath $entry_dir)) { + New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + $result.changed = $true + } + + if ((-not ($entry_target_path.EndsWith("\") -or $entry_target_path.EndsWith("/"))) -and (-not $check_mode)) { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $entry_target_path, $true) + } + $result.changed = $true + } + $archive.Dispose() +} + +Function Expand-ZipLegacy($src, $dest) { + # [System.IO.Compression.ZipFile] was only added in .net 4.5, this is used + # when .net is older than that. + $shell = New-Object -ComObject Shell.Application + $zip = $shell.NameSpace([IO.Path]::GetFullPath($src)) + $dest_path = $shell.NameSpace([IO.Path]::GetFullPath($dest)) + + $shell = New-Object -ComObject Shell.Application + + if (-not $check_mode) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/bb787866.aspx + # From Folder.CopyHere documentation, 1044 means: + # - 1024: do not display a user interface if an error occurs + # - 16: respond with "yes to all" for any dialog box that is displayed + # - 4: do not display a progress dialog box + $dest_path.CopyHere($zip.Items(), 1044) + } + $result.changed = $true +} + +If ($creates -and (Test-Path -LiteralPath $creates)) { + $result.skipped = $true + $result.msg = "The file or directory '$creates' already exists." + Exit-Json -obj $result +} + +If (-Not (Test-Path -LiteralPath $src)) { + Fail-Json -obj $result -message "File '$src' does not exist." +} + +$ext = [System.IO.Path]::GetExtension($src) + +If (-Not (Test-Path -LiteralPath $dest -PathType Container)) { + Try { + New-Item -ItemType "directory" -path $dest -WhatIf:$check_mode | out-null + } + Catch { + Fail-Json -obj $result -message "Error creating '$dest' directory! Msg: $($_.Exception.Message)" + } +} + +If ($ext -eq ".zip" -And $recurse -eq $false -And -Not $password) { + # TODO: PS v5 supports zip extraction, use that if available + $use_legacy = $false + try { + # determines if .net 4.5 is available, if this fails we need to fall + # back to the legacy COM Shell.Application to extract the zip + Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null + Add-Type -AssemblyName System.IO.Compression | Out-Null + } + catch { + $use_legacy = $true + } + + if ($use_legacy) { + try { + Expand-ZipLegacy -src $src -dest $dest + } + catch { + Fail-Json -obj $result -message "Error unzipping '$src' to '$dest'!. Method: COM Shell.Application, Exception: $($_.Exception.Message)" + } + } + else { + try { + Expand-Zip -src $src -dest $dest + } + catch { + Fail-Json -obj $result -message "Error unzipping '$src' to '$dest'!. Method: System.IO.Compression.ZipFile, Exception: $($_.Exception.Message)" + } + } +} +Else { + # Check if PSCX is installed + $list = Get-Module -ListAvailable + + If (-Not ($list -match "PSCX")) { + Fail-Json -obj $result -message "PowerShellCommunityExtensions PowerShell Module (PSCX) is required for non-'.zip' compressed archive types." + } + Else { + $result.pscx_status = "present" + } + + Try { + Import-Module PSCX + } + Catch { + Fail-Json $result "Error importing module PSCX" + } + + $expand_params = @{ + OutputPath = $dest + WhatIf = $check_mode + } + if ($null -ne $password) { + $expand_params.Password = ConvertTo-SecureString -String $password -AsPlainText -Force + } + Try { + Expand-Archive -Path $src @expand_params + } + Catch { + Fail-Json -obj $result -message "Error expanding '$src' to '$dest'! Msg: $($_.Exception.Message)" + } + + If ($recurse) { + Get-ChildItem -LiteralPath $dest -recurse | Where-Object { $pcx_extensions -contains $_.extension } | ForEach-Object { + Try { + Expand-Archive -Path $_.FullName -Force @expand_params + } + Catch { + Fail-Json -obj $result -message "Error recursively expanding '$src' to '$dest'! Msg: $($_.Exception.Message)" + } + If ($delete_archive) { + Remove-Item -LiteralPath $_.FullName -Force -WhatIf:$check_mode + $result.removed = $true + } + } + } + + $result.changed = $true +} + +If ($delete_archive) { + try { + Remove-Item -LiteralPath $src -Recurse -Force -WhatIf:$check_mode + } + catch { + Fail-Json -obj $result -message "failed to delete archive at '$src': $($_.Exception.Message)" + } + $result.removed = $true +} +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_unzip.py b/ansible_collections/community/windows/plugins/modules/win_unzip.py new file mode 100644 index 00000000..f5c46c80 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_unzip.py @@ -0,0 +1,112 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Phil Schwartz <schwartzmx@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_unzip +short_description: Unzips compressed files and archives on the Windows node +description: +- Unzips compressed files and archives. +- Supports .zip files natively. +- Supports other formats supported by the Powershell Community Extensions (PSCX) module (basically everything 7zip supports). +- For non-Windows targets, use the M(ansible.builtin.unarchive) module instead. +requirements: +- PSCX +options: + src: + description: + - File to be unzipped (provide absolute path). + type: path + required: yes + dest: + description: + - Destination of zip file (provide absolute path of directory). If it does not exist, the directory will be created. + type: path + required: yes + delete_archive: + description: + - Remove the zip file, after unzipping. + type: bool + default: no + aliases: [ rm ] + recurse: + description: + - Recursively expand zipped files within the src file. + - Setting to a value of C(yes) requires the PSCX module to be installed. + type: bool + default: no + creates: + description: + - If this file or directory exists the specified src will not be extracted. + type: path + password: + description: + - If a zip file is encrypted with password. + - Passing a value to a password parameter requires the PSCX module to be installed. +notes: +- This module is not really idempotent, it will extract the archive every time, and report a change. +- For extracting any compression types other than .zip, the PowerShellCommunityExtensions (PSCX) Module is required. This module (in conjunction with PSCX) + has the ability to recursively unzip files within the src zip file provided and also functionality for many other compression types. If the destination + directory does not exist, it will be created before unzipping the file. Specifying rm parameter will force removal of the src file after extraction. +seealso: +- module: ansible.builtin.unarchive +author: +- Phil Schwartz (@schwartzmx) +''' + +EXAMPLES = r''' +# This unzips a library that was downloaded with win_get_url, and removes the file after extraction +# $ ansible -i hosts -m win_unzip -a "src=C:\LibraryToUnzip.zip dest=C:\Lib remove=yes" all + +- name: Unzip a bz2 (BZip) file + community.windows.win_unzip: + src: C:\Users\Phil\Logs.bz2 + dest: C:\Users\Phil\OldLogs + creates: C:\Users\Phil\OldLogs + +- name: Unzip gz log + community.windows.win_unzip: + src: C:\Logs\application-error-logs.gz + dest: C:\ExtractedLogs\application-error-logs + +# Unzip .zip file, recursively decompresses the contained .gz files and removes all unneeded compressed files after completion. +- name: Recursively decompress GZ files in ApplicationLogs.zip + community.windows.win_unzip: + src: C:\Downloads\ApplicationLogs.zip + dest: C:\Application\Logs + recurse: yes + delete_archive: yes + +- name: Install PSCX + community.windows.win_psmodule: + name: Pscx + state: present + +- name: Unzip .7z file which is password encrypted + community.windows.win_unzip: + src: C:\Downloads\ApplicationLogs.7z + dest: C:\Application\Logs + password: abcd + delete_archive: yes +''' + +RETURN = r''' +dest: + description: The provided destination path + returned: always + type: str + sample: C:\ExtractedLogs\application-error-logs +removed: + description: Whether the module did remove any files during task run + returned: always + type: bool + sample: true +src: + description: The provided source path + returned: always + type: str + sample: C:\Logs\application-error-logs.gz +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_user_profile.ps1 b/ansible_collections/community/windows/plugins/modules/win_user_profile.ps1 new file mode 100644 index 00000000..babe627c --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_user_profile.ps1 @@ -0,0 +1,169 @@ +#!powershell + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str" } + remove_multiple = @{ type = "bool"; default = $false } + state = @{ type = "str"; default = "present"; choices = @("absent", "present") } + username = @{ type = "sid"; } + } + required_if = @( + @("state", "present", @("username")), + @("state", "absent", @("name", "username"), $true) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$module.Result.path = $null + +$name = $module.Params.name +$remove_multiple = $module.Params.remove_multiple +$state = $module.Params.state +$username = $module.Params.username + +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ansible.WinUserProfile +{ + public class NativeMethods + { + [DllImport("Userenv.dll", CharSet = CharSet.Unicode)] + public static extern int CreateProfile( + [MarshalAs(UnmanagedType.LPWStr)] string pszUserSid, + [MarshalAs(UnmanagedType.LPWStr)] string pszUserName, + [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszProfilePath, + UInt32 cchProfilePath); + + [DllImport("Userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool DeleteProfileW( + [MarshalAs(UnmanagedType.LPWStr)] string lpSidString, + IntPtr lpProfile, + IntPtr lpComputerName); + + [DllImport("Userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool GetProfilesDirectoryW( + [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpProfileDir, + ref UInt32 lpcchSize); + } +} +'@ + +Function Get-LastWin32ExceptionMessage { + param([int]$ErrorCode) + $exp = New-Object -TypeName System.ComponentModel.Win32Exception -ArgumentList $ErrorCode + $exp_msg = "{0} (Win32 ErrorCode {1} - 0x{1:X8})" -f $exp.Message, $ErrorCode + return $exp_msg +} + +Function Get-ExpectedProfilePath { + param([String]$BaseName) + + # Environment.GetFolderPath does not have an enumeration to get the base profile dir, use PInvoke instead + # and combine with the base name to return back to the user - best efforts + $profile_path_length = 0 + [Ansible.WinUserProfile.NativeMethods]::GetProfilesDirectoryW($null, + [ref]$profile_path_length) > $null + + $raw_profile_path = New-Object -TypeName System.Text.StringBuilder -ArgumentList $profile_path_length + $res = [Ansible.WinUserProfile.NativeMethods]::GetProfilesDirectoryW($raw_profile_path, + [ref]$profile_path_length) + + if ($res -eq $false) { + $msg = Get-LastWin32ExceptionMessage -Error ([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) + $module.FailJson("Failed to determine profile path with the base name '$BaseName': $msg") + } + $profile_path = Join-Path -Path $raw_profile_path.ToString() -ChildPath $BaseName + + return $profile_path +} + +$profiles = Get-ChildItem -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" + +if ($state -eq "absent") { + if ($null -ne $username) { + $user_profiles = $profiles | Where-Object { $_.PSChildName -eq $username.Value } + } + else { + # If the username was not provided, or we are removing a profile for a deleted user, we need to try and find + # the correct SID to delete. We just verify that the path matches based on the name passed in + $expected_profile_path = Get-ExpectedProfilePath -BaseName $name + + $user_profiles = $profiles | Where-Object { + $profile_path = (Get-ItemProperty -LiteralPath $_.PSPath -Name ProfileImagePath).ProfileImagePath + $profile_path -eq $expected_profile_path + } + + if ($user_profiles.Length -gt 1 -and -not $remove_multiple) { + $msg = "Found multiple profiles matching the path '$expected_profile_path', set 'remove_multiple=True' to remove all the profiles for this match" + $module.FailJson($msg) + } + } + + foreach ($user_profile in $user_profiles) { + $profile_path = (Get-ItemProperty -LiteralPath $user_profile.PSPath -Name ProfileImagePath).ProfileImagePath + if (-not $module.CheckMode) { + $res = [Ansible.WinUserProfile.NativeMethods]::DeleteProfileW($user_profile.PSChildName, [IntPtr]::Zero, + [IntPtr]::Zero) + if ($res -eq $false) { + $msg = Get-LastWin32ExceptionMessage -Error ([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) + $module.FailJson("Failed to delete the profile for $($user_profile.PSChildName): $msg") + } + } + + # While we may have multiple profiles when the name option was used, it will always be the same path due to + # how we match name to a profile so setting it mutliple time sis fine + $module.Result.path = $profile_path + $module.Result.changed = $true + } +} +elseif ($state -eq "present") { + # Now we know the SID, see if the profile already exists + $user_profile = $profiles | Where-Object { $_.PSChildName -eq $username.Value } + if ($null -eq $user_profile) { + # In case a SID was set as the username we still need to make sure the SID is mapped to a valid local account + try { + $account_name = $username.Translate([System.Security.Principal.NTAccount]) + } + catch [System.Security.Principal.IdentityNotMappedException] { + $module.FailJson("Fail to map the account '$($username.Value)' to a valid user") + } + + # If the basename was not provided, determine it from the actual username + if ($null -eq $name) { + $name = $account_name.Value.Split('\', 2)[-1] + } + + if ($module.CheckMode) { + $profile_path = Get-ExpectedProfilePath -BaseName $name + } + else { + $raw_profile_path = New-Object -TypeName System.Text.StringBuilder -ArgumentList 260 + $res = [Ansible.WinUserProfile.NativeMethods]::CreateProfile($username.Value, $name, $raw_profile_path, + $raw_profile_path.Capacity) + + if ($res -ne 0) { + $exp = [System.Runtime.InteropServices.Marshal]::GetExceptionForHR($res) + $module.FailJson("Failed to create profile for user '$username': $($exp.Message)") + } + $profile_path = $raw_profile_path.ToString() + } + + $module.Result.changed = $true + $module.Result.path = $profile_path + } + else { + $module.Result.path = (Get-ItemProperty -LiteralPath $user_profile.PSPath -Name ProfileImagePath).ProfileImagePath + } +} + +$module.ExitJson() + diff --git a/ansible_collections/community/windows/plugins/modules/win_user_profile.py b/ansible_collections/community/windows/plugins/modules/win_user_profile.py new file mode 100644 index 00000000..280de155 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_user_profile.py @@ -0,0 +1,109 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_user_profile +short_description: Manages the Windows user profiles. +description: +- Used to create or remove user profiles on a Windows host. +- This can be used to create a profile before a user logs on or delete a + profile when removing a user account. +- A profile can be created for both a local or domain account. +options: + name: + description: + - Specifies the base name for the profile path. + - When I(state) is C(present) this is used to create the profile for + I(username) at a specific path within the profile directory. + - This cannot be used to specify a path outside of the profile directory + but rather it specifies a folder(s) within this directory. + - If a profile for another user already exists at the same path, then a 3 + digit incremental number is appended by Windows automatically. + - When I(state) is C(absent) and I(username) is not set, then the module + will remove all profiles that point to the profile path derived by this + value. + - This is useful if the account no longer exists but the profile still + remains. + type: str + remove_multiple: + description: + - When I(state) is C(absent) and the value for I(name) matches multiple + profiles the module will fail. + - Set this value to C(yes) to force the module to delete all the profiles + found. + default: no + type: bool + state: + description: + - Will ensure the profile exists when set to C(present). + - When creating a profile the I(username) option must be set to a valid + account. + - Will remove the profile(s) when set to C(absent). + - When removing a profile either I(username) must be set to a valid + account, or I(name) is set to the profile's base name. + default: present + choices: + - absent + - present + type: str + username: + description: + - The account name of security identifier (SID) for the profile. + - This must be set when I(state) is C(present) and must be a valid account + or the SID of a valid account. + - When I(state) is C(absent) then this must still be a valid account number + but the SID can be a deleted user's SID. + type: sid +seealso: +- module: ansible.windows.win_user +- module: community.windows.win_domain_user +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create a profile for an account + community.windows.win_user_profile: + username: ansible-account + state: present + +- name: Create a profile for an account at C:\Users\ansible + community.windows.win_user_profile: + username: ansible-account + name: ansible + state: present + +- name: Remove a profile for a still valid account + community.windows.win_user_profile: + username: ansible-account + state: absent + +- name: Remove a profile for a deleted account + community.windows.win_user_profile: + name: ansible + state: absent + +- name: Remove a profile for a deleted account based on the SID + community.windows.win_user_profile: + username: S-1-5-21-3233007181-2234767541-1895602582-1305 + state: absent + +- name: Remove multiple profiles that exist at the basename path + community.windows.win_user_profile: + name: ansible + state: absent + remove_multiple: yes +''' + +RETURN = r''' +path: + description: The full path to the profile for the account. This will be null + if C(state=absent) and no profile was deleted. + returned: always + type: str + sample: C:\Users\ansible +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_wait_for_process.ps1 b/ansible_collections/community/windows/plugins/modules/win_wait_for_process.ps1 new file mode 100644 index 00000000..fa9f5a63 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_wait_for_process.ps1 @@ -0,0 +1,176 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# Copyright: (c) 2018, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.SID + +$spec = @{ + options = @{ + process_name_exact = @{ type = 'list'; elements = 'str' } + process_name_pattern = @{ type = 'str' } + pid = @{ type = 'int'; default = 0 } + owner = @{ type = 'str' } + sleep = @{ type = 'int'; default = 1 } + pre_wait_delay = @{ type = 'int'; default = 0 } + post_wait_delay = @{ type = 'int'; default = 0 } + process_min_count = @{ type = 'int'; default = 1 } + state = @{ type = 'str'; default = 'present'; choices = @( 'absent', 'present' ) } + timeout = @{ type = 'int'; default = 300 } + } + mutually_exclusive = @( + @( 'pid', 'process_name_exact' ), + @( 'pid', 'process_name_pattern' ), + @( 'process_name_exact', 'process_name_pattern' ) + ) + required_one_of = @( + , @( 'owner', 'pid', 'process_name_exact', 'process_name_pattern' ) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$process_name_exact = $module.Params.process_name_exact +$process_name_pattern = $module.Params.process_name_pattern +$process_id = $module.Params.pid # pid is a reserved variable in PowerShell, using process_id instead +$owner = $module.Params.owner +$sleep = $module.Params.sleep +$pre_wait_delay = $module.Params.pre_wait_delay +$post_wait_delay = $module.Params.post_wait_delay +$process_min_count = $module.Params.process_min_count +$state = $module.Params.state +$timeout = $module.Params.timeout + +$module.Result.changed = $false +$module.Result.elapsed = 0 +$module.Result.matched_processes = @() + +# Validate the input +if ($state -eq "absent" -and $sleep -ne 1) { + $module.Warn("Parameter 'sleep' has no effect when waiting for a process to stop.") +} + +if ($state -eq "absent" -and $process_min_count -ne 1) { + $module.Warn("Parameter 'process_min_count' has no effect when waiting for a process to stop.") +} + +if ($owner -and ("IncludeUserName" -notin (Get-Command -Name Get-Process).Parameters.Keys)) { + $module.FailJson("This version of Powershell does not support filtering processes by 'owner'.") +} + +Function Get-FilteredProcess { + [cmdletbinding()] + Param( + [String] + $Owner, + $ProcessNameExact, + $ProcessNamePattern, + [int] + $ProcessId + ) + + $FilteredProcesses = @() + + try { + $Processes = Get-Process -IncludeUserName + $SupportsUserNames = $true + } + catch [System.Management.Automation.ParameterBindingException] { + $Processes = Get-Process + $SupportsUserNames = $false + } + + foreach ($Process in $Processes) { + + # If a process name was specified in the filter, validate that here. + if ($ProcessNamePattern) { + if ($Process.ProcessName -notmatch $ProcessNamePattern) { + continue + } + } + + # If a process name was specified in the filter, validate that here. + if ($ProcessNameExact -and $ProcessNameExact -notcontains $Process.ProcessName) { + continue + } + + # If a PID was specified in the filter, validate that here. + if ($ProcessId -and $ProcessId -ne 0) { + if ($ProcessId -ne $Process.Id) { + continue + } + } + + # If an owner was specified in the filter, validate that here. + if ($Owner) { + if (-not $Process.UserName) { + continue + } + elseif ((Convert-ToSID($Owner)) -ne (Convert-ToSID($Process.UserName))) { + # NOTE: This is rather expensive + continue + } + } + + if ($SupportsUserNames -eq $true) { + $FilteredProcesses += @{ name = $Process.ProcessName; pid = $Process.Id; owner = $Process.UserName } + } + else { + $FilteredProcesses += @{ name = $Process.ProcessName; pid = $Process.Id } + } + } + + return , $FilteredProcesses +} + +$module_start = Get-Date +Start-Sleep -Seconds $pre_wait_delay + +if ($state -eq "present" ) { + + # Wait for a process to start + do { + + $Processes = Get-FilteredProcess -Owner $owner -ProcessNameExact $process_name_exact -ProcessNamePattern $process_name_pattern -ProcessId $process_id + $module.Result.matched_processes = $Processes + + if ($Processes.count -ge $process_min_count) { + break + } + + if (((Get-Date) - $module_start).TotalSeconds -gt $timeout) { + $module.Result.elapsed = ((Get-Date) - $module_start).TotalSeconds + $module.FailJson("Timed out while waiting for process(es) to start") + } + + Start-Sleep -Seconds $sleep + + } while ($true) + +} +elseif ($state -eq "absent") { + + # Wait for a process to stop + $Processes = Get-FilteredProcess -Owner $owner -ProcessNameExact $process_name_exact -ProcessNamePattern $process_name_pattern -ProcessId $process_id + $module.Result.matched_processes = $Processes + + if ($Processes.count -gt 0 ) { + try { + # This may randomly fail when used on specially protected processes (think: svchost) + Wait-Process -Id $Processes.pid -Timeout $timeout + } + catch [System.TimeoutException] { + $module.Result.elapsed = ((Get-Date) - $module_start).TotalSeconds + $module.FailJson("Timeout while waiting for process(es) to stop") + } + } + +} + +Start-Sleep -Seconds $post_wait_delay +$module.Result.elapsed = ((Get-Date) - $module_start).TotalSeconds + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_wait_for_process.py b/ansible_collections/community/windows/plugins/modules/win_wait_for_process.py new file mode 100644 index 00000000..6f483d10 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_wait_for_process.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_wait_for_process +short_description: Waits for a process to exist or not exist before continuing. +description: +- Waiting for a process to start or stop. +- This is useful when Windows services behave poorly and do not enumerate external dependencies in their manifest. +options: + process_name_exact: + description: + - The name of the process(es) for which to wait. The name of the process(es) should not include the file extension suffix. + type: list + elements: str + process_name_pattern: + description: + - RegEx pattern matching desired process(es). + type: str + sleep: + description: + - Number of seconds to sleep between checks. + - Only applies when waiting for a process to start. Waiting for a process to start + does not have a native non-polling mechanism. Waiting for a stop uses native PowerShell + and does not require polling. + type: int + default: 1 + process_min_count: + description: + - Minimum number of process matching the supplied pattern to satisfy C(present) condition. + - Only applies to C(present). + type: int + default: 1 + pid: + description: + - The PID of the process. + default: 0 + type: int + owner: + description: + - The owner of the process. + - Requires PowerShell version 4.0 or newer. + type: str + pre_wait_delay: + description: + - Seconds to wait before checking processes. + type: int + default: 0 + post_wait_delay: + description: + - Seconds to wait after checking for processes. + type: int + default: 0 + state: + description: + - When checking for a running process C(present) will block execution + until the process exists, or until the timeout has been reached. + C(absent) will block execution until the process no longer exists, + or until the timeout has been reached. + - When waiting for C(present), the module will return changed only if + the process was not present on the initial check but became present on + subsequent checks. + - If, while waiting for C(absent), new processes matching the supplied + pattern are started, these new processes will not be included in the + action. + type: str + default: present + choices: [ absent, present ] + timeout: + description: + - The maximum number of seconds to wait for a for a process to start or stop + before erroring out. + type: int + default: 300 +seealso: +- module: ansible.builtin.wait_for +- module: ansible.windows.win_wait_for +author: +- Charles Crossan (@crossan007) +''' + +EXAMPLES = r''' +- name: Wait 300 seconds for all Oracle VirtualBox processes to stop. (VBoxHeadless, VirtualBox, VBoxSVC) + community.windows.win_wait_for_process: + process_name_pattern: 'v(irtual)?box(headless|svc)?' + state: absent + timeout: 500 + +- name: Wait 300 seconds for 3 instances of cmd to start, waiting 5 seconds between each check + community.windows.win_wait_for_process: + process_name_exact: cmd + state: present + timeout: 500 + sleep: 5 + process_min_count: 3 +''' + +RETURN = r''' +elapsed: + description: The elapsed seconds between the start of poll and the end of the module. + returned: always + type: float + sample: 3.14159265 +matched_processes: + description: List of matched processes (either stopped or started). + returned: always + type: complex + contains: + name: + description: The name of the matched process. + returned: always + type: str + sample: svchost + owner: + description: The owner of the matched process. + returned: when supported by PowerShell + type: str + sample: NT AUTHORITY\SYSTEM + pid: + description: The PID of the matched process. + returned: always + type: int + sample: 7908 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_wakeonlan.ps1 b/ansible_collections/community/windows/plugins/modules/win_wakeonlan.ps1 new file mode 100644 index 00000000..ea4a4a5e --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_wakeonlan.ps1 @@ -0,0 +1,52 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + mac = @{ type = 'str'; required = $true } + broadcast = @{ type = 'str'; default = '255.255.255.255' } + port = @{ type = 'int'; default = 7 } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.changed = $false + +$mac = $module.Params.mac +$mac_orig = $module.Params.mac +$broadcast = $module.Params.broadcast +$port = $module.Params.port + +$broadcast = [Net.IPAddress]::Parse($broadcast) + +# Remove possible separator from MAC address +if ($mac.Length -eq (12 + 5)) { + $mac = $mac.Replace($mac.Substring(2, 1), "") +} + +# If we don't end up with 12 hexadecimal characters, fail +if ($mac.Length -ne 12) { + $module.FailJson("Incorrect MAC address: $mac_orig") +} + +# Create payload for magic packet +# TODO: Catch possible conversion errors +$target = 0, 2, 4, 6, 8, 10 | ForEach-Object { [convert]::ToByte($mac.Substring($_, 2), 16) } +$data = (, [byte]255 * 6) + ($target * 20) + +# Broadcast payload to network +$udpclient = new-Object System.Net.Sockets.UdpClient +if (-not $module.CheckMode) { + $udpclient.Connect($broadcast, $port) + [void] $udpclient.Send($data, 102) +} + +$module.Result.changed = $true + +$module.ExitJson() diff --git a/ansible_collections/community/windows/plugins/modules/win_wakeonlan.py b/ansible_collections/community/windows/plugins/modules/win_wakeonlan.py new file mode 100644 index 00000000..b9ba920b --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_wakeonlan.py @@ -0,0 +1,57 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Dag Wieers <dag@wieers.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_wakeonlan +short_description: Send a magic Wake-on-LAN (WoL) broadcast packet +description: +- The C(win_wakeonlan) module sends magic Wake-on-LAN (WoL) broadcast packets. +- For non-Windows targets, use the M(community.general.wakeonlan) module instead. +options: + mac: + description: + - MAC address to send Wake-on-LAN broadcast packet for. + type: str + required: yes + broadcast: + description: + - Network broadcast address to use for broadcasting magic Wake-on-LAN packet. + type: str + default: 255.255.255.255 + port: + description: + - UDP port to use for magic Wake-on-LAN packet. + type: int + default: 7 +todo: +- Does not have SecureOn password support +notes: +- This module sends a magic packet, without knowing whether it worked. It always report a change. +- Only works if the target system was properly configured for Wake-on-LAN (in the BIOS and/or the OS). +- Some BIOSes have a different (configurable) Wake-on-LAN boot order (i.e. PXE first). +seealso: +- module: community.general.wakeonlan +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Send a magic Wake-on-LAN packet to 00:00:5E:00:53:66 + community.windows.win_wakeonlan: + mac: 00:00:5E:00:53:66 + broadcast: 192.0.2.23 + +- name: Send a magic Wake-On-LAN packet on port 9 to 00-00-5E-00-53-66 + community.windows.win_wakeonlan: + mac: 00-00-5E-00-53-66 + port: 9 + delegate_to: remote_system +''' + +RETURN = r''' +# Default return values +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_webpicmd.ps1 b/ansible_collections/community/windows/plugins/modules/win_webpicmd.ps1 new file mode 100644 index 00000000..f888d7d0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_webpicmd.ps1 @@ -0,0 +1,103 @@ +#!powershell + +# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +Function Find-InstalledCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, Position = 0)] [string] $command + ) + $installed = get-command $command -erroraction Ignore + write-verbose "$installed" + if ($installed) { + return $installed + } + return $null +} + +Function Find-WebPiCmd { + [CmdletBinding()] + param() + $p = Find-InstalledCommand "webpicmd.exe" + if ($null -ne $p) { + return $p + } + $a = Find-InstalledCommand "c:\programdata\chocolatey\bin\webpicmd.exe" + if ($null -ne $a) { + return $a + } + Throw "webpicmd.exe is not installed. It must be installed (use chocolatey)" +} + +Function Test-IsInstalledFromWebPI { + [CmdletBinding()] + + param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$package + ) + + $results = &$executable /list /listoption:installed + + if ($LastExitCode -ne 0) { + $result.webpicmd_error_cmd = $cmd + $result.webpicmd_error_log = "$results" + + Throw "Error checking installation status for $package" + } + Write-Verbose "$results" + + if ($results -match "^$package\s+") { + return $true + } + + return $false +} + +Function Install-WithWebPICmd { + [CmdletBinding()] + + param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$package + ) + + $results = &$executable /install /products:$package /accepteula /suppressreboot + + if ($LastExitCode -ne 0) { + $result.webpicmd_error_cmd = $cmd + $result.webpicmd_error_log = "$results" + Throw "Error installing $package" + } + + write-verbose "$results" + + if ($results -match "Install of Products: SUCCESS") { + $result.changed = $true + } +} + +$result = @{ + changed = $false +} + +$params = Parse-Args $args + +$package = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true + +Try { + $script:executable = Find-WebPiCmd + if ((Test-IsInstalledFromWebPI -package $package) -eq $false) { + Install-WithWebPICmd -package $package + } + + Exit-Json $result +} +Catch { + Fail-Json $result $_.Exception.Message +} diff --git a/ansible_collections/community/windows/plugins/modules/win_webpicmd.py b/ansible_collections/community/windows/plugins/modules/win_webpicmd.py new file mode 100644 index 00000000..16745fb0 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_webpicmd.py @@ -0,0 +1,35 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_webpicmd +short_description: Installs packages using Web Platform Installer command-line +description: + - Installs packages using Web Platform Installer command-line + (U(http://www.iis.net/learn/install/web-platform-installer/web-platform-installer-v4-command-line-webpicmdexe-rtw-release)). + - Must be installed and present in PATH (see M(chocolatey.chocolatey.win_chocolatey) module; 'webpicmd' is the package name, and you must install + 'lessmsi' first too)? + - Install IIS first (see M(ansible.windows.win_feature) module). +notes: + - Accepts EULAs and suppresses reboot - you will need to check manage reboots yourself (see M(ansible.windows.win_reboot) module) +options: + name: + description: + - Name of the package to be installed. + type: str + required: yes +seealso: +- module: ansible.windows.win_package +author: +- Peter Mounce (@petemounce) +''' + +EXAMPLES = r''' +- name: Install URLRewrite2. + community.windows.win_webpicmd: + name: URLRewrite2 +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_xml.ps1 b/ansible_collections/community/windows/plugins/modules/win_xml.ps1 new file mode 100644 index 00000000..6f5db138 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_xml.ps1 @@ -0,0 +1,285 @@ +#!powershell + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.Backup + +Set-StrictMode -Version 2 + +function Copy-Xml($dest, $src, $xmlorig) { + if ($src.get_NodeType() -eq "Text") { + $dest.set_InnerText($src.get_InnerText()) + } + + if ($src.get_HasAttributes()) { + foreach ($attr in $src.get_Attributes()) { + $dest.SetAttribute($attr.get_Name(), $attr.get_Value()) + } + } + + if ($src.get_HasChildNodes()) { + foreach ($childnode in $src.get_ChildNodes()) { + if ($childnode.get_NodeType() -eq "Element") { + $newnode = $xmlorig.CreateElement($childnode.get_Name(), $xmlorig.get_DocumentElement().get_NamespaceURI()) + Copy-Xml -dest $newnode -src $childnode -xmlorig $xmlorig + $dest.AppendChild($newnode) | Out-Null + } + elseif ($childnode.get_NodeType() -eq "Text") { + $dest.set_InnerText($childnode.get_InnerText()) + } + } + } +} + +function Compare-XmlDoc($actual, $expected) { + if ($actual.get_Name() -ne $expected.get_Name()) { + throw "Actual name not same as expected: actual=" + $actual.get_Name() + ", expected=" + $expected.get_Name() + } + ##attributes... + + if (($actual.get_NodeType() -eq "Element") -and ($expected.get_NodeType() -eq "Element")) { + if ($actual.get_HasAttributes() -and $expected.get_HasAttributes()) { + if ($actual.get_Attributes().Count -ne $expected.get_Attributes().Count) { + throw "attribute mismatch for actual=" + $actual.get_Name() + } + for ($i = 0; $i -lt $expected.get_Attributes().Count; $i = $i + 1) { + if ($expected.get_Attributes()[$i].get_Name() -ne $actual.get_Attributes()[$i].get_Name()) { + throw "attribute name mismatch for actual=" + $actual.get_Name() + } + if ($expected.get_Attributes()[$i].get_Value() -ne $actual.get_Attributes()[$i].get_Value()) { + throw "attribute value mismatch for actual=" + $actual.get_Name() + } + } + } + + if (($actual.get_HasAttributes() -and !$expected.get_HasAttributes()) -or (!$actual.get_HasAttributes() -and $expected.get_HasAttributes())) { + throw "attribute presence mismatch for actual=" + $actual.get_Name() + } + } + + ##children + if ($expected.get_ChildNodes().Count -ne $actual.get_ChildNodes().Count) { + throw "child node mismatch. for actual=" + $actual.get_Name() + } + + for ($i = 0; $i -lt $expected.get_ChildNodes().Count; $i = $i + 1) { + if (-not $actual.get_ChildNodes()[$i]) { + throw "actual missing child nodes. for actual=" + $actual.get_Name() + } + Compare-XmlDoc $expected.get_ChildNodes()[$i] $actual.get_ChildNodes()[$i] + } + + if ($expected.get_InnerText()) { + if ($expected.get_InnerText() -ne $actual.get_InnerText()) { + throw "inner text mismatch for actual=" + $actual.get_Name() + } + } + elseif ($actual.get_InnerText()) { + throw "actual has inner text but expected does not for actual=" + $actual.get_Name() + } +} + + +function Save-ChangedXml($xmlorig, $result, $message, $check_mode, $backup) { + $result.changed = $true + if (-Not $check_mode) { + if ($backup) { + $result.backup_file = Backup-File -path $dest -WhatIf:$check_mode + # Ensure backward compatibility (deprecate in future) + $result.backup = $result.backup_file + } + $xmlorig.Save($dest) + $result.msg = $message + } + else { + $result.msg += " check mode" + } +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$debug_level = Get-AnsibleParam -obj $params -name "_ansible_verbosity" -type "int" +$debug = $debug_level -gt 2 + +$dest = Get-AnsibleParam $params "path" -type "path" -FailIfEmpty $true -aliases "dest", "file" +$fragment = Get-AnsibleParam $params "fragment" -type "str" -aliases "xmlstring" +$xpath = Get-AnsibleParam $params "xpath" -type "str" -FailIfEmpty $true +$backup = Get-AnsibleParam $params "backup" -type "bool" -Default $false +$type = Get-AnsibleParam $params "type" -type "str" -Default "element" -ValidateSet "element", "attribute", "text" +$attribute = Get-AnsibleParam $params "attribute" -type "str" -FailIfEmpty ($type -eq "attribute") +$state = Get-AnsibleParam $params "state" -type "str" -Default "present" +$count = Get-AnsibleParam $params "count" -type "bool" -Default $false + +$result = @{ + changed = $false +} + +If (-Not (Test-Path -LiteralPath $dest -PathType Leaf)) { + Fail-Json $result "Specified path $dest does not exist or is not a file." +} + +$xmlorig = New-Object -TypeName System.Xml.XmlDocument +$xmlorig.XmlResolver = $null +Try { + $xmlorig.Load($dest) +} +Catch { + Fail-Json $result "Failed to parse file at '$dest' as an XML document: $($_.Exception.Message)" +} + +$namespaceMgr = New-Object System.Xml.XmlNamespaceManager $xmlorig.NameTable +$namespace = $xmlorig.DocumentElement.NamespaceURI +$localname = $xmlorig.DocumentElement.LocalName + +$namespaceMgr.AddNamespace($xmlorig.$localname.SchemaInfo.Prefix, $namespace) + +$nodeList = $xmlorig.SelectNodes($xpath, $namespaceMgr) +$nodeListCount = $nodeList.get_Count() +if ($count) { + $result.count = $nodeListCount + if (-not $fragment) { + Exit-Json $result + } +} +## Exit early if xpath did not match any nodes +if ($nodeListCount -eq 0) { + $result.msg = "The supplied xpath did not match any nodes. If this is unexpected, check your xpath is valid for the xml file at supplied path." + Exit-Json $result +} + +$changed = $false +$result.msg = "not changed" + +if ($type -eq "element") { + if ($state -eq "absent") { + + $removals = [System.Collections.Generic.List[String]]@() + + foreach ($node in $nodeList) { + # there are some nodes that match xpath, delete without comparing them to fragment + if (-Not $check_mode) { + [void]$node.get_ParentNode().RemoveChild($node) + $changed = $true + } + + if ($debug) { + $removals.Add($node.get_OuterXml()) + } + } + + if ($removals) { + $result.removed = $removals -join ", " + } + } + else { + # state -eq 'present' + $xmlfragment = $null + Try { + $xmlfragment = [xml]$fragment + } + Catch { + Fail-Json $result "Failed to parse fragment as XML: $($_.Exception.Message)" + } + + foreach ($node in $nodeList) { + $candidate = $xmlorig.CreateElement($xmlfragment.get_DocumentElement().get_Name(), $xmlorig.get_DocumentElement().get_NamespaceURI()) + Copy-Xml -dest $candidate -src $xmlfragment.DocumentElement -xmlorig $xmlorig + + if ($node.get_NodeType() -eq "Document") { + $node = $node.get_DocumentElement() + } + $elements = $node.get_ChildNodes() + [bool]$present = $false + [bool]$changed = $false + if ($elements.get_Count()) { + if ($debug) { + $err = @() + $result.err = { $err }.Invoke() + } + foreach ($element in $elements) { + try { + Compare-XmlDoc $candidate $element + $present = $true + break + } + catch { + if ($debug) { + $result.err.Add($_.Exception.ToString()) + } + } + } + if (-Not $present -and ($state -eq "present")) { + [void]$node.AppendChild($candidate) + $result.msg = $result.msg + "xml added " + $changed = $true + } + } + } + } +} +elseif ($type -eq "text") { + foreach ($node in $nodeList) { + if ($node.get_InnerText() -ne $fragment) { + $node.set_InnerText($fragment) + $changed = $true + } + } +} +elseif ($type -eq "attribute") { + foreach ($node in $nodeList) { + if ($state -eq 'present') { + if ($node.NodeType -eq 'Attribute') { + if ($node.Name -eq $attribute -and $node.Value -ne $fragment ) { + # this is already the attribute with the right name, so just set its value to match fragment + $node.Value = $fragment + $changed = $true + } + } + else { + # assume NodeType is Element + if (!$node.HasAttribute($attribute) -or ($node.$attribute -ne $fragment)) { + if (!$node.HasAttribute($attribute)) { + # add attribute to Element if missing + $node.SetAttributeNode($attribute, $xmlorig.get_DocumentElement().get_NamespaceURI()) + } + #set the attribute into the element + $node.SetAttribute($attribute, $fragment) + $changed = $true + } + } + } + elseif ($state -eq 'absent') { + if ($node.NodeType -eq 'Attribute') { + $attrNode = [System.Xml.XmlAttribute]$node + $parent = $attrNode.OwnerElement + $parent.RemoveAttribute($attribute) + $changed = $true + } + else { + # element node processing + if ($node.Name -eq $attribute ) { + # note not caring about the state of 'fragment' at this point + $node.RemoveAttribute($attribute) + $changed = $true + } + } + } + else { + Add-Warning $result "Unexpected state when processing attribute $($attribute), add was $add, state was $state" + } + } +} +if ($changed) { + if ($state -eq "absent") { + $summary = "$type removed" + } + else { + $summary = "$type changed" + } + Save-ChangedXml -xmlorig $xmlorig -result $result -message $summary -check_mode $check_mode -backup $backup +} + +Exit-Json $result diff --git a/ansible_collections/community/windows/plugins/modules/win_xml.py b/ansible_collections/community/windows/plugins/modules/win_xml.py new file mode 100644 index 00000000..a069c7b5 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_xml.py @@ -0,0 +1,139 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_xml +short_description: Manages XML file content on Windows hosts +description: + - Manages XML nodes, attributes and text, using xpath to select which xml nodes need to be managed. + - XML fragments, formatted as strings, are used to specify the desired state of a part or parts of XML files on remote Windows servers. + - For non-Windows targets, use the M(community.general.xml) module instead. +options: + attribute: + description: + - The attribute name if the type is 'attribute'. + - Required if C(type=attribute). + type: str + count: + description: + - When set to C(yes), return the number of nodes matched by I(xpath). + type: bool + default: false + backup: + description: + - Determine whether a backup should be created. + - When set to C(yes), create a backup file including the timestamp information + so you can get the original file back if you somehow clobbered it incorrectly. + type: bool + default: no + fragment: + description: + - The string representation of the XML fragment expected at xpath. Since ansible 2.9 not required when I(state=absent), or when I(count=yes). + type: str + required: false + aliases: [ xmlstring ] + path: + description: + - Path to the file to operate on. + type: path + required: true + aliases: [ dest, file ] + state: + description: + - Set or remove the nodes (or attributes) matched by I(xpath). + type: str + default: present + choices: [ present, absent ] + type: + description: + - The type of XML node you are working with. + type: str + default: element + choices: [ attribute, element, text ] + xpath: + description: + - Xpath to select the node or nodes to operate on. + type: str + required: true +author: + - Richard Levenberg (@richardcs) + - Jon Hawkesworth (@jhawkesworth) +notes: + - Only supports operating on xml elements, attributes and text. + - Namespace, processing-instruction, command and document node types cannot be modified with this module. +seealso: + - module: community.general.xml + description: XML manipulation for Posix hosts. + - name: w3shools XPath tutorial + description: A useful tutorial on XPath + link: https://www.w3schools.com/xml/xpath_intro.asp +''' + +EXAMPLES = r''' +- name: Apply our filter to Tomcat web.xml + community.windows.win_xml: + path: C:\apache-tomcat\webapps\myapp\WEB-INF\web.xml + fragment: '<filter><filter-name>MyFilter</filter-name><filter-class>com.example.MyFilter</filter-class></filter>' + xpath: '/*' + +- name: Apply sslEnabledProtocols to Tomcat's server.xml + community.windows.win_xml: + path: C:\Tomcat\conf\server.xml + xpath: '//Server/Service[@name="Catalina"]/Connector[@port="9443"]' + attribute: 'sslEnabledProtocols' + fragment: 'TLSv1,TLSv1.1,TLSv1.2' + type: attribute + +- name: remove debug configuration nodes from nlog.conf + community.windows.win_xml: + path: C:\IISApplication\nlog.conf + xpath: /nlog/rules/logger[@name="debug"]/descendant::* + state: absent + +- name: count configured connectors in Tomcat's server.xml + community.windows.win_xml: + path: C:\Tomcat\conf\server.xml + xpath: //Server/Service/Connector + count: yes + register: connector_count + +- name: show connector count + debug: + msg="Connector count is {{connector_count.count}}" + +- name: ensure all lang=en attributes to lang=nl + community.windows.win_xml: + path: C:\Data\Books.xml + xpath: //@[lang="en"] + attribute: lang + fragment: nl + type: attribute + +''' + +RETURN = r''' +backup_file: + description: Name of the backup file that was created. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +count: + description: Number of nodes matched by xpath. + returned: if count=yes + type: int + sample: 33 +msg: + description: What was done. + returned: always + type: str + sample: "xml added" +err: + description: XML comparison exceptions. + returned: always, for type element and -vvv or more + type: list + sample: attribute mismatch for actual=string +''' diff --git a/ansible_collections/community/windows/plugins/modules/win_zip.ps1 b/ansible_collections/community/windows/plugins/modules/win_zip.ps1 new file mode 100644 index 00000000..08cedfb7 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_zip.ps1 @@ -0,0 +1,73 @@ +#!powershell + +# Copyright: (c) 2021, Kento Yagisawa <thel.vadam2485@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + # Need to support \* which type='path' does not, the path is expanded further down. + src = @{ type = 'str'; required = $true } + dest = @{ type = 'path'; required = $true } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$src = [Environment]::ExpandEnvironmentVariables($module.Params.src) +$dest = $module.Params.dest + +$srcFile = [System.IO.Path]::GetFileName($src) +$compressionLevel = [System.IO.Compression.CompressionLevel]::Optimal +$encoding = New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false +$srcWildcard = $false + +# If the path ends with '\*' we want to include the dir contents and not the dir itself +If ($src -match '\\\*$') { + $srcWildcard = $true + $src = $src.Substring(0, $src.Length - 2) +} + +If (-not (Test-Path -LiteralPath $src)) { + $module.FailJson("The source file or directory '$src' does not exist.") +} + +If ($dest -notlike "*.zip") { + $module.FailJson("The destination zip file path '$dest' need to be zip file path.") +} + +If (Test-Path -LiteralPath $dest) { + $module.Result.msg = "The destination zip file '$dest' already exists." + $module.ExitJson() +} + +# Check .NET v4.5 or later version exists or not +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop +} +catch { + $module.FailJson(".NET Framework 4.5 or later version needs to be installed.", $_) +} + +Function Compress-Zip($src, $dest) { + If (-not $module.CheckMode) { + If (Test-Path -LiteralPath $src -PathType Container) { + [System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dest, $compressionLevel, (-not $srcWildcard), $encoding) + } + Else { + $zip = [System.IO.Compression.ZipFile]::Open($dest, 'Update') + try { + [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $src, $srcFile, $compressionLevel) + } + finally { + $zip.Dispose() + } + } + } + $module.Result.changed = $true +} + +Compress-Zip -src $src -dest $dest + +$module.ExitJson()
\ No newline at end of file diff --git a/ansible_collections/community/windows/plugins/modules/win_zip.py b/ansible_collections/community/windows/plugins/modules/win_zip.py new file mode 100644 index 00000000..53ea1458 --- /dev/null +++ b/ansible_collections/community/windows/plugins/modules/win_zip.py @@ -0,0 +1,53 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Kento Yagisawa <thel.vadam2485@gmail.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r''' +--- +module: win_zip +short_description: Compress file or directory as zip archive on the Windows node +description: +- Compress file or directory as zip archive. +- For non-Windows targets, use the M(community.general.archive) module instead. +notes: +- The filenames in the zip are encoded using UTF-8. +requirements: +- .NET Framework 4.5 or later +options: + src: + description: + - File or directory path to be zipped (provide absolute path on the target node). + - When a directory path the directory is zipped as the root entry in the archive. + - Specify C(\*) to the end of I(src) to zip the contents of the directory and not the directory itself. + type: str + required: yes + dest: + description: + - Destination path of zip file (provide absolute path of zip file on the target node). + type: path + required: yes +seealso: +- module: community.general.archive +author: +- Kento Yagisawa (@hiyoko_taisa) +''' + +EXAMPLES = r''' +- name: Compress a file + community.windows.win_zip: + src: C:\Users\hiyoko\log.txt + dest: C:\Users\hiyoko\log.zip + +- name: Compress a directory as the root of the archive + community.windows.win_zip: + src: C:\Users\hiyoko\log + dest: C:\Users\hiyoko\log.zip + +- name: Compress the directories contents + community.windows.win_zip: + src: C:\Users\hiyoko\log\* + dest: C:\Users\hiyoko\log.zip + +''' |