Add new windows module: win_hosts (#46450)

* Add win_hosts module

added win_hosts module for easier manipulation of hosts entries in "%windir%\system32\drivers\etc\hosts" for windows systems

* Update win_hosts.py

* Add alias support to win_hosts module (#1)

* win_hosts supports aliases

added support for adding / removing aliases from a host entry, rather than adding a new entry

added ability for win_hosts to detect aliases:
`192.168.1.1 alias1 alias2 alias3`
```
win_hosts:
  host_name: alias2
  ip_address: 192.168.1.1
```
will result in `192.168.1.1 alias1 alias3`

also includes `replace` and `add` as options for `ip_action` (`replace` is default)

for example:
```
192.168.1.1 my_reused_alias
192.168.1.2 my_reused_alias
```
with
```
win_hosts:
  host_name: my_reused_alias
  ip_address: 192.168.1.3
  ip_action: add
```
the result will be
```
192.168.1.1 my_reused_alias
192.168.1.2 my_reused_alias
```
but with `ip_action=replace` the result would be
```
192.168.1.3 my_reused_alias
```

* fixed metadata version and version added

* fix line endings

* upload fixed line endings

try to upload the file with the fixed line endings

* aliases and canonical names are separate entities. added IPv4 and IPv6 validation

* only makes changes if "check_mode" is false

* improved behavior for duplicate aliases/entries.

* adding tests

* missing aliases file

* fix trailing whitespace and uses explicit paths

* Tweak tests to copy and restore original hosts file
This commit is contained in:
Micah Hunsberger 2019-03-31 16:54:05 -04:00 committed by Jordan Borean
parent 09979e899f
commit 26d9341891
7 changed files with 638 additions and 0 deletions

View file

@ -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

View file

@ -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'''
'''

View file

@ -0,0 +1 @@
shippable/windows/group3

View file

@ -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

View file

@ -0,0 +1,2 @@
dependencies:
- setup_remote_tmp_dir

View file

@ -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

View file

@ -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"