diff --git a/lib/ansible/modules/windows/win_hosts.ps1 b/lib/ansible/modules/windows/win_hosts.ps1 new file mode 100644 index 00000000000..64d183e5d03 --- /dev/null +++ b/lib/ansible/modules/windows/win_hosts.ps1 @@ -0,0 +1,298 @@ +#!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) + +#Requires -Module Ansible.ModuleUtils.Legacy + +Set-StrictMode -Version 2 +$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 + +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent","present" +$aliases = Get-AnsibleParam -obj $params -name "aliases" -type "list" -failifempty $false +$canonical_name = Get-AnsibleParam -obj $params -name "canonical_name" -type "str" -failifempty ($state -eq 'present') +$ip_address = Get-AnsibleParam -obj $params -name "ip_address" -type "str" -default "" -failifempty ($state -eq 'present') +$action = Get-AnsibleParam -obj $params -name "action" -type "str" -default "set" -validateset "add","remove","set" + +$tmp = [ipaddress]::None +if($ip_address -and -not [ipaddress]::TryParse($ip_address, [ref]$tmp)){ + Fail-Json -obj @{} -message "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" + +$result = @{ + changed = $false + diff = @{ + prepared = "" + } +} + +Function Get-CommentIndex($line) { + $c_index = $line.IndexOf('#') + if($c_index -lt 0) { + $c_index = $line.Length + } + return $c_index +} + +Function Get-HostEntryParts($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) { + $result.changed = $true + $removed = $false + + if($diff_mode) { + $result.diff.prepared += "`n-[$($list[$idx])]`n" + } + + if(-not $check_mode) { + $list.RemoveAt($idx) + $removed = $true + } + + return $removed +} + +Function Add-HostEntry($list, $cname, $aliases, $ip) { + $result.changed = $true + $line = "$ip $cname $($aliases -join ' ')" + if($diff_mode) { + $result.diff.prepared += "`n+[$line]`n" + } + if(-not $check_mode) { + $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") { + if($diff_mode){ + $result.diff.prepared += "`n-[$($list[$idx])]`n" + } + if(-not $check_mode) { + $list.RemoveAt($idx) + $line_removed = $true + } + # we're done + return @{ + line_removed = $line_removed + } + } + } + } + if($line -ne $list[$idx]){ + $result.changed = $true + if($diff_mode) { + $result.diff.prepared += "`n-[$($list[$idx])]`n+[$line]`n" + } + if(-not $check_mode) { + $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]){ + $result.changed = $true + if($diff_mode) { + $result.diff.prepared += "`n-[$($list[$idx])]`n+[$line]`n" + } + if(-not $check_mode) { + $list[$idx] = $line + } + } +} + +$hosts_lines = New-Object System.Collections.ArrayList + +Get-Content -LiteralPath $hosts_file.FullName | ForEach-Object { $hosts_lines.Add($_) } | Out-Null + +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-HostEntryParts -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-HostEntryParts -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) { + # don't need to worry about line being removed since canonical_name is present + $entry_idx = $idx + + if($action -eq 'set') { + # remove the entry's aliases that are not in $aliases + $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 the entry + if (Remove-HostEntry -list $hosts_lines -idx $idx){ + # keep index correct if we removed the line + $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") { + # just remove canonical_name. user may want alias(es) mapped to this canonical name + $aliases_to_remove = $canonical_name + } elseif ($aliases -contains $entry_parts.canonical_name) { + # remove the entry + if (Remove-HostEntry -list $hosts_lines -idx $idx) { + # keep index correct if we removed the line + $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 { + # ensure canonical_name and aliases removed from this entry + $aliases_to_remove = $aliases + $canonical_name + } + } else { + # Just ignore if the types don't match. + # 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) { + # keep index correct if we removed the line + $idx = $idx - 1 + } + } + } + } + } + + if($entry_idx -ge 0) { + # we found the entry + $aliases_to_add = @() + $entry_parts = Get-HostEntryParts -line $hosts_lines[$entry_idx] + if($action -eq 'remove') { + # just preserve any previously removed aliases + $aliases_to_add = $aliases_to_keep | Where-Object { $entry_parts.aliases -notcontains $_ } + } else { + # we want to add provided aliases and previously removed aliases that are not already in the list + $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) + } + } +} + +if( $result.changed -and -not $check_mode ) { + Set-Content -LiteralPath $hosts_file.FullName -Value $hosts_lines +} + +Exit-Json $result diff --git a/lib/ansible/modules/windows/win_hosts.py b/lib/ansible/modules/windows/win_hosts.py new file mode 100644 index 00000000000..87355c00d8c --- /dev/null +++ b/lib/ansible/modules/windows/win_hosts.py @@ -0,0 +1,118 @@ +#!/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) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_hosts +version_added: '2.8' +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 C(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 C(ip_address) is provided when C(state=absent), then all + hosts entries with the ip address of I(ip_address) will be removed. + - If C(ip_address) and C(canonical_name) are both omitted when + C(state=absent), then all hosts entries will be removed. + choices: + - absent + - present + default: present + canonical_name: + description: + - A canonical name for the host entry. + - required for C(state=present). + 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). + aliases: + description: + - A list of additional names (cname records) for the host entry. + - Only applicable when C(state=present). + action: + choices: + - add + - remove + - set + description: + - Controls the behavior of C(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 +author: + - Micah Hunsberger (@mhunsber) +notes: + - Each canonical name can only be mapped to one IPv4 and one IPv6 address. + If C(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 C(ip_address), then C(canonical_name) and all C(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 C(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 added to or removed + from (based on I(action)) an entry with the provided canonical name. + - See also M(win_template), M(win_file), M(win_copy) +''' + +EXAMPLES = r''' +- name: Add 127.0.0.1 as an A record for localhost + win_hosts: + state: present + canonical_name: localhost + ip_address: 127.0.0.1 + +- name: Add ::1 as an AAAA record for localhost + win_environment: + state: present + canonical_name: localhost + ip_address: '::1' + +- name: Remove 'bar' and 'zed' from the list of aliases for foo (192.168.1.100) + win_hosts: + state: present + canoncial_name: foo + ip_address: 192.168.1.100 + action: remove + aliases: + - bar + - zed + +- name: Remove hosts entries with canonical name 'bar' + win_hosts: + state: absent + canonical_name: bar + +- name: Remove 10.2.0.1 from the list of hosts + win_hosts: + state: absent + ip_address: 10.2.0.1 + +- name: Ensure all name resolution is handled by DNS + win_hosts: + state: absent +''' + +RETURN = r''' +''' diff --git a/test/integration/targets/win_hosts/aliases b/test/integration/targets/win_hosts/aliases new file mode 100644 index 00000000000..3cf5b97e805 --- /dev/null +++ b/test/integration/targets/win_hosts/aliases @@ -0,0 +1 @@ +shippable/windows/group3 diff --git a/test/integration/targets/win_hosts/defaults/main.yml b/test/integration/targets/win_hosts/defaults/main.yml new file mode 100644 index 00000000000..c6270216d68 --- /dev/null +++ b/test/integration/targets/win_hosts/defaults/main.yml @@ -0,0 +1,13 @@ +--- +test_win_hosts_cname: testhost +test_win_hosts_ip: 192.168.168.1 + +test_win_hosts_aliases_set: + - alias1 + - alias2 + - alias3 + - alias4 + +test_win_hosts_aliases_remove: + - alias3 + - alias4 diff --git a/test/integration/targets/win_hosts/meta/main.yml b/test/integration/targets/win_hosts/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/win_hosts/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/win_hosts/tasks/main.yml b/test/integration/targets/win_hosts/tasks/main.yml new file mode 100644 index 00000000000..0997375f9fd --- /dev/null +++ b/test/integration/targets/win_hosts/tasks/main.yml @@ -0,0 +1,17 @@ +--- +- name: take a copy of the original hosts file + win_copy: + src: C:\Windows\System32\drivers\etc\hosts + dest: '{{ remote_tmp_dir }}\hosts' + remote_src: yes + +- block: + - name: run tests + include_tasks: tests.yml + + always: + - name: restore hosts file + win_copy: + src: '{{ remote_tmp_dir }}\hosts' + dest: C:\Windows\System32\drivers\etc\hosts + remote_src: yes diff --git a/test/integration/targets/win_hosts/tasks/tests.yml b/test/integration/targets/win_hosts/tasks/tests.yml new file mode 100644 index 00000000000..a29e01a708b --- /dev/null +++ b/test/integration/targets/win_hosts/tasks/tests.yml @@ -0,0 +1,189 @@ +--- + +- name: add a simple host with address + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: add_ip + +- assert: + that: + - "add_ip.changed == true" + +- name: get actual dns result + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: add_ip_actual + +- assert: + that: + - "add_ip_actual.stdout_lines[0]|lower == 'true'" + +- name: add a simple host with ipv4 address (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: add_ip + +- assert: + that: + - "add_ip.changed == false" + +- name: remove simple host + win_hosts: + state: absent + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: remove_ip + +- assert: + that: + - "remove_ip.changed == true" + +- name: get actual dns result + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname}}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_ip_actual + failed_when: "remove_ip_actual.rc == 0" + +- assert: + that: + - "remove_ip_actual.stdout_lines[0]|lower == 'false'" + +- name: remove simple host (idempotent) + win_hosts: + state: absent + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: remove_ip + +- assert: + that: + - "remove_ip.changed == false" + +- name: add host and set aliases + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + action: set + register: set_aliases + +- assert: + that: + - "set_aliases.changed == true" + +- name: get actual dns result for host + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: set_aliases_actual_host + +- assert: + that: + - "set_aliases_actual_host.stdout_lines[0]|lower == 'true'" + +- name: get actual dns results for aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: set_aliases_actual + with_items: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ set_aliases_actual.results }}" + +- name: add host and set aliases (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + action: set + register: set_aliases + +- assert: + that: + - "set_aliases.changed == false" + +- name: remove aliases from the list + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: remove + register: remove_aliases + +- assert: + that: + - "remove_aliases.changed == true" + +- name: get actual dns result for removed aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_aliases_removed_actual + failed_when: "remove_aliases_removed_actual.rc == 0" + with_items: "{{ test_win_hosts_aliases_remove }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'false'" + with_items: "{{ remove_aliases_removed_actual.results }}" + +- name: get actual dns result for remaining aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_aliases_remain_actual + with_items: "{{ test_win_hosts_aliases_set | difference(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ remove_aliases_remain_actual.results }}" + +- name: remove aliases from the list (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: remove + register: remove_aliases + +- assert: + that: + - "remove_aliases.changed == false" + +- name: add aliases back + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: add + register: add_aliases + +- assert: + that: + - "add_aliases.changed == true" + +- name: get actual dns results for aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: add_aliases_actual + with_items: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ add_aliases_actual.results }}" + +- name: add aliases back (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: add + register: add_aliases + +- assert: + that: + - "add_aliases.changed == false"