diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
commit | 975f66f2eebe9dadba04f275774d4ab83f74cf25 (patch) | |
tree | 89bd26a93aaae6a25749145b7e4bca4a1e75b2be /ansible_collections/community/hrobot/plugins/modules | |
parent | Initial commit. (diff) | |
download | ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.tar.xz ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.zip |
Adding upstream version 7.7.0+dfsg.upstream/7.7.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/community/hrobot/plugins/modules')
12 files changed, 3449 insertions, 0 deletions
diff --git a/ansible_collections/community/hrobot/plugins/modules/boot.py b/ansible_collections/community/hrobot/plugins/modules/boot.py new file mode 100644 index 000000000..64917d9b8 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/boot.py @@ -0,0 +1,444 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: boot +short_description: Set boot configuration +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Set the boot configuration for a dedicated server. +seealso: + - module: community.hrobot.ssh_key + description: Add, remove or update SSH key + - module: community.hrobot.ssh_key_info + description: Query information on SSH keys +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: none + +options: + server_number: + description: + - The server number of the server whose boot configuration to adjust. + type: int + required: true + regular_boot: + description: + - If this option is provided, all special boot configurations are removed and + the installed operating system will be booted up next (assuming it is bootable). + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: bool + choices: + - true + rescue: + description: + - If this option is provided, the rescue system will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + os: + description: + - The operating system to use for the rescue system. Possible choices can + change over time. + - Currently, C(linux), C(linuxold), C(freebsd), C(freebsdold), C(freebsdax), + C(freebsdbetaax), C(vkvm), and C(vkvmold) seem to be available. + type: str + required: true + arch: + description: + - The architecture to use for the rescue system. + - Not all architectures are available for all operating systems. + - Defaults to C(64). + type: int + choices: + - 32 + - 64 + authorized_keys: + description: + - One or more SSH key fingerprints to equip the rescue system with. + - Only fingerprints for SSH keys deposited in the Robot API can be used. + - You can use the M(community.hrobot.ssh_key_info) module to query the + SSH keys you can use, and the M(community.hrobot.ssh_key) module to + add or update SSH keys. + type: list + elements: str + install_linux: + description: + - If this option is provided, a Linux system install will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + dist: + description: + - The distribution to install. + type: str + required: true + arch: + description: + - The architecture to use for the install. + - Not all architectures are available for all distributions. + - Defaults to C(64). + type: int + choices: + - 32 + - 64 + lang: + description: + - The language to use for the operating system. + type: str + required: true + authorized_keys: + description: + - One or more SSH key fingerprints to equip the rescue system with. + - Only fingerprints for SSH keys deposited in the Robot API can be used. + - You can use the M(community.hrobot.ssh_key_info) module to query the + SSH keys you can use, and the M(community.hrobot.ssh_key) module to + add or update SSH keys. + type: list + elements: str + install_vnc: + description: + - If this option is provided, a VNC installation will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + dist: + description: + - The distribution to install. + type: str + required: true + arch: + description: + - The architecture to use for the install. + - Not all architectures are available for all distributions. + - Defaults to C(64). + type: int + choices: + - 32 + - 64 + lang: + description: + - The language to use for the operating system. + type: str + required: true + install_windows: + description: + - If this option is provided, a Windows installation will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + lang: + description: + - The language to use for Windows. + type: str + required: true + install_plesk: + description: + - If this option is provided, a Plesk installation will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + dist: + description: + - The distribution to install. + type: str + required: true + arch: + description: + - The architecture to use for the install. + - Not all architectures are available for all distributions. + - Defaults to C(64). + type: int + choices: + - 32 + - 64 + lang: + description: + - The language to use for the operating system. + type: str + required: true + hostname: + description: + - The hostname. + type: str + required: true + install_cpanel: + description: + - If this option is provided, a cPanel installation will be activated for the next boot. + - Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc), + I(install_windows), I(install_plesk), and I(install_cpanel) must be provided. + type: dict + suboptions: + dist: + description: + - The distribution to install. + type: str + required: true + arch: + description: + - The architecture to use for the install. + - Not all architectures are available for all distributions. + - Defaults to C(64). + type: int + choices: + - 32 + - 64 + lang: + description: + - The language to use for the operating system. + type: str + required: true + hostname: + description: + - The hostname. + type: str + required: true +''' + +EXAMPLES = r''' +- name: Disable all special boot configurations + community.hrobot.boot: + hetzner_user: foo + hetzner_password: bar + regular_boot: true + +- name: Enable a rescue system (64bit Linux) for the next boot + community.hrobot.boot: + hetzner_user: foo + hetzner_password: bar + rescue: + os: linux + +- name: Enable a Linux install for the next boot + community.hrobot.boot: + hetzner_user: foo + hetzner_password: bar + install_linux: + dist: CentOS 5.5 minimal + lang: en + authorized_keys: + - 56:29:99:a4:5d:ed:ac:95:c1:f5:88:82:90:5d:dd:10 + - 15:28:b0:03:95:f0:77:b3:10:56:15:6b:77:22:a5:bb +''' + +RETURN = r''' +configuration_type: + description: + - Describes the active boot configuration. + returned: success + type: str + choices: + - regular_boot + - rescue + - install_linux + - install_vnc + - install_windows + - install_plesk + - install_cpanel +password: + description: + - The root password for the active boot configuration, if available. + - For non-rescue boot configurations, it is avised to change the root password + as soon as possible. + returned: success and if a boot configuration other than C(regular_boot) is active + type: str +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +BOOT_CONFIGURATION_DATA = [ + ('rescue', 'rescue', { + 'os': ('os', 'os'), + 'arch': ('arch', 'arch'), + 'authorized_keys': ('authorized_key', 'authorized_key'), + }), + ('install_linux', 'linux', { + 'dist': ('dist', 'dist'), + 'arch': ('arch', 'arch'), + 'lang': ('lang', 'lang'), + 'authorized_keys': ('authorized_key', 'authorized_key'), + }), + ('install_vnc', 'vnc', { + 'dist': ('dist', 'dist'), + 'arch': ('arch', 'arch'), + 'lang': ('lang', 'lang'), + }), + ('install_windows', 'windows', { + 'lang': ('lang', 'lang'), + }), + ('install_plesk', 'plesk', { + 'dist': ('dist', 'dist'), + 'arch': ('arch', 'arch'), + 'lang': ('lang', 'lang'), + 'hostname': ('hostname', 'hostname'), + }), + ('install_cpanel', 'cpanel', { + 'dist': ('dist', 'dist'), + 'arch': ('arch', 'arch'), + 'lang': ('lang', 'lang'), + 'hostname': ('hostname', 'hostname'), + }), +] + + +def main(): + argument_spec = dict( + server_number=dict(type='int', required=True), + regular_boot=dict(type='bool', choices=[True]), + rescue=dict(type='dict', options=dict( + os=dict(type='str', required=True), + arch=dict(type='int', choices=[32, 64]), + authorized_keys=dict(type='list', elements='str', no_log=False), + )), + install_linux=dict(type='dict', options=dict( + dist=dict(type='str', required=True), + arch=dict(type='int', choices=[32, 64]), + lang=dict(type='str', required=True), + authorized_keys=dict(type='list', elements='str', no_log=False), + )), + install_vnc=dict(type='dict', options=dict( + dist=dict(type='str', required=True), + arch=dict(type='int', choices=[32, 64]), + lang=dict(type='str', required=True), + )), + install_windows=dict(type='dict', options=dict( + lang=dict(type='str', required=True), + )), + install_plesk=dict(type='dict', options=dict( + dist=dict(type='str', required=True), + arch=dict(type='int', choices=[32, 64]), + lang=dict(type='str', required=True), + hostname=dict(type='str', required=True), + )), + install_cpanel=dict(type='dict', options=dict( + dist=dict(type='str', required=True), + arch=dict(type='int', choices=[32, 64]), + lang=dict(type='str', required=True), + hostname=dict(type='str', required=True), + )), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[('regular_boot', 'rescue', 'install_linux', 'install_vnc', 'install_windows', 'install_plesk', 'install_cpanel')], + required_one_of=[('regular_boot', 'rescue', 'install_linux', 'install_vnc', 'install_windows', 'install_plesk', 'install_cpanel')], + ) + + server_number = module.params['server_number'] + changed = False + + # Retrieve current boot config + url = "{0}/boot/{1}".format(BASE_URL, server_number) + result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND', 'BOOT_NOT_AVAILABLE']) + if error is not None: + if error == 'SERVER_NOT_FOUND': + module.fail_json(msg='This server does not exist, or you do not have access rights for it') + if error == 'BOOT_NOT_AVAILABLE': + module.fail_json(msg='There is no boot configuration available for this server') + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + + # Deactivate current boot configurations that are not requested + for option_name, other_name, dummy in BOOT_CONFIGURATION_DATA: + if (result['boot'].get(other_name) or {}).get('active') and not module.params[option_name]: + changed = True + if not module.check_mode: + url = "{0}/boot/{1}/{2}".format(BASE_URL, server_number, other_name) + fetch_url_json(module, url, method='DELETE', allow_empty_result=True) + + # Enable/compare boot configuration + return_values = { + 'configuration_type': 'regular_boot', + 'password': None, + } + for option_name, other_name, options in BOOT_CONFIGURATION_DATA: + if module.params[option_name]: + return_values['configuration_type'] = option_name + existing = result['boot'].get(other_name) or {} + return_values['password'] = existing.get('password') + data = {} + for option_key, (result_key, data_key) in options.items(): + option = module.params[option_name][option_key] + if option is None or option == []: + continue + data[data_key] = option + if existing.get('active'): + # Idempotence check + needs_change = False + for option_key, (result_key, data_key) in options.items(): + should = module.params[option_name][option_key] + if should is None: + continue + # unfold the return object for the idempotence check to work correctly + has = existing.get(data_key) + if has and option_key == 'authorized_keys': + has = [x['key']['fingerprint'] for x in has] + if isinstance(has, list): + has = sorted(has) + if not isinstance(should, list): + should = [should] + should = sorted(should) + if should != has: + needs_change = True + else: + needs_change = True + + if needs_change: + changed = True + if not module.check_mode: + url = "{0}/boot/{1}/{2}".format(BASE_URL, server_number, other_name) + if existing.get('active'): + # Deactivate existing boot configuration + fetch_url_json(module, url, method='DELETE', allow_empty_result=True) + # Enable new boot configuration + headers = {"Content-type": "application/x-www-form-urlencoded"} + result, dummy = fetch_url_json( + module, + url, + data=urlencode(data, True), + headers=headers, + method='POST', + ) + return_values['password'] = (result.get(other_name) or {}).get('password') + else: + return_values['password'] = None + + module.exit_json(changed=changed, **return_values) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/failover_ip.py b/ansible_collections/community/hrobot/plugins/modules/failover_ip.py new file mode 100644 index 000000000..da2da356a --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/failover_ip.py @@ -0,0 +1,154 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: failover_ip +short_description: Manage Hetzner's failover IPs +author: + - Felix Fontein (@felixfontein) +description: + - Manage Hetzner's failover IPs. +seealso: + - name: Failover IP documentation + description: Hetzner's documentation on failover IPs. + link: https://docs.hetzner.com/robot/dedicated-server/ip/failover/ + - module: community.hrobot.failover_ip_info + description: Retrieve information on failover IPs. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: full + +options: + failover_ip: + description: The failover IP address. + type: str + required: true + state: + description: + - Defines whether the IP will be routed or not. + - If set to C(routed), I(value) must be specified. + type: str + choices: + - routed + - unrouted + default: routed + value: + description: + - The new value for the failover IP address. + - Required when setting I(state) to C(routed). + type: str + timeout: + description: + - Timeout to use when routing or unrouting the failover IP. + - Note that the API call returns when the failover IP has been + successfully routed to the new address, respectively successfully + unrouted. + type: int + default: 180 +''' + +EXAMPLES = r''' +- name: Set value of failover IP 1.2.3.4 to 5.6.7.8 + community.hrobot.failover_ip: + hetzner_user: foo + hetzner_password: bar + failover_ip: 1.2.3.4 + value: 5.6.7.8 + +- name: Set value of failover IP 1.2.3.4 to unrouted + community.hrobot.failover_ip: + hetzner_user: foo + hetzner_password: bar + failover_ip: 1.2.3.4 + state: unrouted +''' + +RETURN = r''' +value: + description: + - The value of the failover IP. + - Will be C(none) if the IP is unrouted. + returned: success + type: str +state: + description: + - Will be C(routed) or C(unrouted). + returned: success + type: str +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + ROBOT_DEFAULT_ARGUMENT_SPEC, +) +from ansible_collections.community.hrobot.plugins.module_utils.failover import ( + get_failover, + set_failover, + get_failover_state, +) + + +def main(): + argument_spec = dict( + failover_ip=dict(type='str', required=True), + state=dict(type='str', default='routed', choices=['routed', 'unrouted']), + value=dict(type='str'), + timeout=dict(type='int', default=180), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=( + ('state', 'routed', ['value']), + ), + ) + + failover_ip = module.params['failover_ip'] + value = get_failover(module, failover_ip) + changed = False + before = get_failover_state(value) + + if module.params['state'] == 'routed': + new_value = module.params['value'] + else: + new_value = None + + if value != new_value: + if module.check_mode: + value = new_value + changed = True + else: + value, changed = set_failover(module, failover_ip, new_value, timeout=module.params['timeout']) + + after = get_failover_state(value) + module.exit_json( + changed=changed, + diff=dict( + before=before, + after=after, + ), + **after + ) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/failover_ip_info.py b/ansible_collections/community/hrobot/plugins/modules/failover_ip_info.py new file mode 100644 index 000000000..b656b0499 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/failover_ip_info.py @@ -0,0 +1,127 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: failover_ip_info +short_description: Retrieve information on Hetzner's failover IPs +author: + - Felix Fontein (@felixfontein) +description: + - Retrieve information on Hetzner's failover IPs. +seealso: + - name: Failover IP documentation + description: Hetzner's documentation on failover IPs. + link: https://docs.hetzner.com/robot/dedicated-server/ip/failover/ + - module: community.hrobot.failover_ip + description: Manage failover IPs. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + - community.hrobot.attributes.info_module + +attributes: + action_group: + version_added: 1.6.0 + +options: + failover_ip: + description: The failover IP address. + type: str + required: true +''' + +EXAMPLES = r''' +- name: Get value of failover IP 1.2.3.4 + community.hrobot.failover_ip_info: + hetzner_user: foo + hetzner_password: bar + failover_ip: 1.2.3.4 + value: 5.6.7.8 + register: result + +- name: Print value of failover IP 1.2.3.4 in case it is routed + ansible.builtin.debug: + msg: "1.2.3.4 routes to {{ result.value }}" + when: result.state == 'routed' +''' + +RETURN = r''' +value: + description: + - The value of the failover IP. + - Will be C(none) if the IP is unrouted. + returned: success + type: str +state: + description: + - Will be C(routed) or C(unrouted). + returned: success + type: str +failover_ip: + description: + - The failover IP. + returned: success + type: str + sample: '1.2.3.4' +failover_netmask: + description: + - The netmask for the failover IP. + returned: success + type: str + sample: '255.255.255.255' +server_ip: + description: + - The main IP of the server this failover IP is associated to. + - This is I(not) the server the failover IP is routed to. + returned: success + type: str +server_number: + description: + - The number of the server this failover IP is associated to. + - This is I(not) the server the failover IP is routed to. + returned: success + type: int +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + ROBOT_DEFAULT_ARGUMENT_SPEC, +) +from ansible_collections.community.hrobot.plugins.module_utils.failover import ( + get_failover_record, + get_failover_state, +) + + +def main(): + argument_spec = dict( + failover_ip=dict(type='str', required=True), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + failover = get_failover_record(module, module.params['failover_ip']) + result = get_failover_state(failover['active_server_ip']) + result['failover_ip'] = failover['ip'] + result['failover_netmask'] = failover['netmask'] + result['server_ip'] = failover['server_ip'] + result['server_number'] = failover['server_number'] + result['changed'] = False + module.exit_json(**result) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/firewall.py b/ansible_collections/community/hrobot/plugins/modules/firewall.py new file mode 100644 index 000000000..2677a1186 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/firewall.py @@ -0,0 +1,704 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: firewall +short_description: Manage Hetzner's dedicated server firewall +author: + - Felix Fontein (@felixfontein) +description: + - Manage Hetzner's dedicated server firewall. + - Note that idempotency check for TCP flags simply compares strings and doesn't + try to interpret the rules. This might change in the future. +requirements: + - ipaddress +seealso: + - name: Firewall documentation + description: Hetzner's documentation on the stateless firewall for dedicated servers + link: https://docs.hetzner.com/robot/dedicated-server/firewall/ + - module: community.hrobot.firewall_info + description: Retrieve information on firewall configuration. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: full + +options: + server_ip: + description: + - The server's main IP address. + - Exactly one of I(server_ip) and I(server_number) must be specified. + - Note that Hetzner deprecated identifying the server's firewall by the server's main IP. + Using this option can thus stop working at any time in the future. Use I(server_number) instead. + type: str + server_number: + description: + - The server's number. + - Exactly one of I(server_ip) and I(server_number) must be specified. + type: int + version_added: 1.8.0 + filter_ipv6: + description: + - Whether to filter IPv6 traffic as well. + - IPv4 traffic is always filtered, IPv6 traffic filtering needs to be explicitly enabled. + type: bool + version_added: 1.8.0 + port: + description: + - Switch port of firewall. + type: str + choices: [ main, kvm ] + default: main + state: + description: + - Status of the firewall. + - Firewall is active if state is C(present), and disabled if state is C(absent). + type: str + default: present + choices: [ present, absent ] + allowlist_hos: + description: + - Whether Hetzner services have access. + type: bool + aliases: + - whitelist_hos + rules: + description: + - Firewall rules. + type: dict + suboptions: + input: + description: + - Input firewall rules. + type: list + elements: dict + suboptions: + name: + description: + - Name of the firewall rule. + type: str + ip_version: + description: + - Internet protocol version. + - Leave away to filter both protocols. Note that in that case, none of I(dst_ip), I(src_ip), or I(protocol) can be specified. + type: str + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + dst_port: + description: + - Destination port or port range. + type: str + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + src_port: + description: + - Source port or port range. + type: str + protocol: + description: + - Protocol above IP layer. + type: str + tcp_flags: + description: + - TCP flags or logical combination of flags. + - Flags supported by Hetzner are C(syn), C(fin), C(rst), C(psh) and C(urg). + - They can be combined with C(|) (logical or) and C(&) (logical and). + - See L(the documentation,https://wiki.hetzner.de/index.php/Robot_Firewall/en#Parameter) + for more information. + type: str + action: + description: + - Action if rule matches. + required: true + type: str + choices: [ accept, discard ] + output: + description: + - Output firewall rules. + type: list + elements: dict + version_added: 1.8.0 + suboptions: + name: + description: + - Name of the firewall rule. + type: str + ip_version: + description: + - Internet protocol version. + - Leave away to filter both protocols. Note that in that case, none of I(dst_ip), I(src_ip), or I(protocol) can be specified. + type: str + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + dst_port: + description: + - Destination port or port range. + type: str + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + src_port: + description: + - Source port or port range. + type: str + protocol: + description: + - Protocol above IP layer. + type: str + tcp_flags: + description: + - TCP flags or logical combination of flags. + - Flags supported by Hetzner are C(syn), C(fin), C(rst), C(psh) and C(urg). + - They can be combined with C(|) (logical or) and C(&) (logical and). + - See L(the documentation,https://wiki.hetzner.de/index.php/Robot_Firewall/en#Parameter) + for more information. + type: str + action: + description: + - Action if rule matches. + required: true + type: str + choices: [ accept, discard ] + update_timeout: + description: + - Timeout to use when configuring the firewall. + - Note that the API call returns before the firewall has been + successfully set up. + type: int + default: 30 + wait_for_configured: + description: + - Whether to wait until the firewall has been successfully configured before + determining what to do, and before returning from the module. + - The API returns status C(in progress) when the firewall is currently + being configured. If this happens, the module will try again until + the status changes to C(active) or C(disabled). + - Please note that there is a request limit. If you have to do multiple + updates, it can be better to disable waiting, and regularly use + M(community.hrobot.firewall_info) to query status. + type: bool + default: true + wait_delay: + description: + - Delay to wait (in seconds) before checking again whether the firewall has + been configured. + type: int + default: 10 + timeout: + description: + - Timeout (in seconds) for waiting for firewall to be configured. + type: int + default: 180 +''' + +EXAMPLES = r''' +- name: Configure firewall for server with main IP 1.2.3.4 + community.hrobot.firewall: + hetzner_user: foo + hetzner_password: bar + server_ip: 1.2.3.4 + state: present + filter_ipv6: true + allowlist_hos: true + rules: + input: + - name: Allow ICMP protocol, so you can ping your server + ip_version: ipv4 + protocol: icmp + action: accept + # Note that it is not possible to disable ICMP for IPv6 + # (https://robot.hetzner.com/doc/webservice/en.html#post-firewall-server-id) + - name: Allow responses to incoming TCP connections + protocol: tcp + dst_port: '32768-65535' + tcp_flags: ack + action: accept + - name: Allow everything to ports 20-23 from 4.3.2.1/24 (IPv4 only) + ip_version: ipv4 + src_ip: 4.3.2.1/24 + dst_port: '20-23' + action: accept + - name: Allow everything to port 443 + dst_port: '443' + action: accept + - name: Drop everything else + action: discard + output: + - name: Accept everything + action: accept + register: result + +- ansible.builtin.debug: + msg: "{{ result }}" +''' + +RETURN = r''' +firewall: + description: + - The firewall configuration. + type: dict + returned: success + contains: + port: + description: + - Switch port of firewall. + - C(main) or C(kvm). + type: str + sample: main + server_ip: + description: + - Server's main IP address. + type: str + sample: 1.2.3.4 + server_number: + description: + - Hetzner's internal server number. + type: int + sample: 12345 + status: + description: + - Status of the firewall. + - C(active) or C(disabled). + - Will be C(in process) if the firewall is currently updated, and + I(wait_for_configured) is set to C(false) or I(timeout) to a too small value. + type: str + sample: active + allowlist_hos: + description: + - Whether Hetzner services have access. + type: bool + sample: true + version_added: 1.2.0 + whitelist_hos: + description: + - Whether Hetzner services have access. + - Old name of return value C(allowlist_hos), will be removed eventually. + type: bool + sample: true + rules: + description: + - Firewall rules. + type: dict + contains: + input: + description: + - Input firewall rules. + type: list + elements: dict + contains: + name: + description: + - Name of the firewall rule. + type: str + sample: Allow HTTP access to server + ip_version: + description: + - Internet protocol version. + - No value means the rule applies both to IPv4 and IPv6. + type: str + sample: ipv4 + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + sample: 1.2.3.4/32 + dst_port: + description: + - Destination port or port range. + type: str + sample: "443" + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + sample: null + src_port: + description: + - Source port or port range. + type: str + sample: null + protocol: + description: + - Protocol above IP layer. + type: str + sample: tcp + tcp_flags: + description: + - TCP flags or logical combination of flags. + type: str + sample: null + action: + description: + - Action if rule matches. + - C(accept) or C(discard). + type: str + sample: accept + choices: + - accept + - discard + output: + description: + - Output firewall rules. + type: list + elements: dict + contains: + name: + description: + - Name of the firewall rule. + type: str + sample: Allow HTTP access to server + ip_version: + description: + - Internet protocol version. + - No value means the rule applies both to IPv4 and IPv6. + type: str + sample: ~ + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + sample: 1.2.3.4/32 + dst_port: + description: + - Destination port or port range. + type: str + sample: "443" + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + sample: null + src_port: + description: + - Source port or port range. + type: str + sample: null + protocol: + description: + - Protocol above IP layer. + type: str + sample: tcp + tcp_flags: + description: + - TCP flags or logical combination of flags. + type: str + sample: null + action: + description: + - Action if rule matches. + - C(accept) or C(discard). + type: str + sample: accept + choices: + - accept + - discard +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + ROBOT_DEFAULT_ARGUMENT_SPEC, + BASE_URL, + fetch_url_json, + fetch_url_json_with_retries, + CheckDoneTimeoutException, +) +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils.common.text.converters import to_native, to_text + +try: + import ipaddress + HAS_IPADDRESS = True + IPADDRESS_IMP_ERR = None +except ImportError as exc: + IPADDRESS_IMP_ERR = traceback.format_exc() + HAS_IPADDRESS = False + + +RULE_OPTION_NAMES = [ + 'name', 'ip_version', 'dst_ip', 'dst_port', 'src_ip', 'src_port', + 'protocol', 'tcp_flags', 'action', +] + +RULES = ['input', 'output'] + + +def restrict_dict(dictionary, fields): + result = dict() + for k, v in dictionary.items(): + if k in fields: + result[k] = v + return result + + +def restrict_firewall_config(config): + result = restrict_dict(config, ['port', 'status', 'filter_ipv6', 'whitelist_hos']) + result['rules'] = dict() + for ruleset in RULES: + result['rules'][ruleset] = [ + restrict_dict(rule, RULE_OPTION_NAMES) + for rule in config['rules'].get(ruleset) or [] + ] + return result + + +def update(before, after, params, name, param_name=None): + bv = before.get(name) + after[name] = bv + changed = False + pv = params[param_name or name] + if pv is not None: + changed = pv != bv + if changed: + after[name] = pv + return changed + + +def normalize_ip(ip, ip_version): + if ip is None or ip_version is None: + return ip + if '/' in ip: + ip, range = ip.split('/') + else: + ip, range = ip, '' # pylint: disable=self-assigning-variable + ip_addr = to_native(ipaddress.ip_address(to_text(ip)).compressed) + if range == '': + range = '32' if ip_version.lower() == 'ipv4' else '128' + return ip_addr + '/' + range + + +def update_rules(before, after, params, ruleset): + before_rules = before['rules'][ruleset] + after_rules = after['rules'][ruleset] + params_rules = params['rules'][ruleset] + changed = len(before_rules) != len(params_rules) + for no, rule in enumerate(params_rules): + rule['src_ip'] = normalize_ip(rule['src_ip'], rule['ip_version']) + rule['dst_ip'] = normalize_ip(rule['dst_ip'], rule['ip_version']) + if no < len(before_rules): + before_rule = before_rules[no] + before_rule['src_ip'] = normalize_ip(before_rule['src_ip'], before_rule['ip_version']) + before_rule['dst_ip'] = normalize_ip(before_rule['dst_ip'], before_rule['ip_version']) + if before_rule != rule: + changed = True + after_rules.append(rule) + return changed + + +def encode_rule(output, rulename, input): + for i, rule in enumerate(input['rules'][rulename]): + for k, v in rule.items(): + if v is not None: + output['rules[{0}][{1}][{2}]'.format(rulename, i, k)] = v + + +def create_default_rules_object(): + rules = dict() + for ruleset in RULES: + rules[ruleset] = [] + return rules + + +def fix_naming(firewall_result): + firewall_result = firewall_result.copy() + firewall_result['allowlist_hos'] = firewall_result.get('whitelist_hos', False) + return firewall_result + + +def firewall_configured(result, error): + return result['firewall']['status'] != 'in process' + + +def main(): + argument_spec = dict( + server_ip=dict(type='str'), + server_number=dict(type='int'), + port=dict(type='str', default='main', choices=['main', 'kvm']), + filter_ipv6=dict(type='bool'), + state=dict(type='str', default='present', choices=['present', 'absent']), + allowlist_hos=dict(type='bool', aliases=['whitelist_hos']), + rules=dict(type='dict', options=dict( + input=dict(type='list', elements='dict', options=dict( + name=dict(type='str'), + ip_version=dict(type='str'), + dst_ip=dict(type='str'), + dst_port=dict(type='str'), + src_ip=dict(type='str'), + src_port=dict(type='str'), + protocol=dict(type='str'), + tcp_flags=dict(type='str'), + action=dict(type='str', required=True, choices=['accept', 'discard']), + ), required_by=dict(dst_ip=['ip_version'], src_ip=['ip_version'], protocol=['ip_version'])), + output=dict(type='list', elements='dict', options=dict( + name=dict(type='str'), + ip_version=dict(type='str'), + dst_ip=dict(type='str'), + dst_port=dict(type='str'), + src_ip=dict(type='str'), + src_port=dict(type='str'), + protocol=dict(type='str'), + tcp_flags=dict(type='str'), + action=dict(type='str', required=True, choices=['accept', 'discard']), + ), required_by=dict(dst_ip=['ip_version'], src_ip=['ip_version'], protocol=['ip_version'])), + )), + update_timeout=dict(type='int', default=30), + wait_for_configured=dict(type='bool', default=True), + wait_delay=dict(type='int', default=10), + timeout=dict(type='int', default=180), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + if not HAS_IPADDRESS: + module.fail_json(msg=missing_required_lib('ipaddress'), exception=IPADDRESS_IMP_ERR) + + # Sanitize input + module.params['status'] = 'active' if (module.params['state'] == 'present') else 'disabled' + if module.params['rules'] is None: + module.params['rules'] = {} + for chain in RULES: + if module.params['rules'].get(chain) is None: + module.params['rules'][chain] = [] + + server_id = module.params['server_ip'] or module.params['server_number'] + + # https://robot.your-server.de/doc/webservice/en.html#get-firewall-server-ip + url = "{0}/firewall/{1}".format(BASE_URL, server_id) + if module.params['wait_for_configured']: + try: + result, error = fetch_url_json_with_retries( + module, + url, + check_done_callback=firewall_configured, + check_done_delay=module.params['wait_delay'], + check_done_timeout=module.params['timeout'], + ) + except CheckDoneTimeoutException as dummy: + module.fail_json(msg='Timeout while waiting for firewall to be configured.') + else: + result, error = fetch_url_json(module, url) + if not firewall_configured(result, error): + module.fail_json(msg='Firewall configuration cannot be read as it is not configured.') + + full_before = result['firewall'] + if not full_before.get('rules'): + full_before['rules'] = create_default_rules_object() + before = restrict_firewall_config(full_before) + + # Build wanted (after) state and compare + after = dict(before) + changed = False + changed |= update(before, after, module.params, 'filter_ipv6') + changed |= update(before, after, module.params, 'port') + changed |= update(before, after, module.params, 'status') + changed |= update(before, after, module.params, 'whitelist_hos', 'allowlist_hos') + after['rules'] = create_default_rules_object() + if module.params['status'] == 'active': + for ruleset in RULES: + changed |= update_rules(before, after, module.params, ruleset) + + # Update if different + construct_result = True + construct_status = None + if changed and not module.check_mode: + # https://robot.your-server.de/doc/webservice/en.html#post-firewall-server-ip + url = "{0}/firewall/{1}".format(BASE_URL, server_id) + headers = {"Content-type": "application/x-www-form-urlencoded"} + data = dict(after) + data['filter_ipv6'] = str(data['filter_ipv6']).lower() + data['whitelist_hos'] = str(data['whitelist_hos']).lower() + del data['rules'] + for ruleset in RULES: + encode_rule(data, ruleset, after) + result, error = fetch_url_json( + module, + url, + method='POST', + timeout=module.params['update_timeout'], + data=urlencode(data), + headers=headers, + ) + if module.params['wait_for_configured'] and not firewall_configured(result, error): + try: + result, error = fetch_url_json_with_retries( + module, + url, + check_done_callback=firewall_configured, + check_done_delay=module.params['wait_delay'], + check_done_timeout=module.params['timeout'], + skip_first=True, + ) + except CheckDoneTimeoutException as e: + result, error = e.result, e.error + module.warn('Timeout while waiting for firewall to be configured.') + + full_after = result['firewall'] + if not full_after.get('rules'): + full_after['rules'] = create_default_rules_object() + construct_status = full_after['status'] + if construct_status != 'in process': + # Only use result if configuration is done, so that diff will be ok + after = restrict_firewall_config(full_after) + construct_result = False + + if construct_result: + # Construct result (used for check mode, and configuration still in process) + full_after = dict(full_before) + for k, v in after.items(): + if k != 'rules': + full_after[k] = after[k] + if construct_status is not None: + # We want 'in process' here + full_after['status'] = construct_status + full_after['rules'] = dict() + for ruleset in RULES: + full_after['rules'][ruleset] = after['rules'][ruleset] + + module.exit_json( + changed=changed, + diff=dict( + before=fix_naming(before), + after=fix_naming(after), + ), + firewall=fix_naming(full_after), + ) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/firewall_info.py b/ansible_collections/community/hrobot/plugins/modules/firewall_info.py new file mode 100644 index 000000000..49f98ab64 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/firewall_info.py @@ -0,0 +1,318 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: firewall_info +short_description: Manage Hetzner's dedicated server firewall +author: + - Felix Fontein (@felixfontein) +description: + - Manage Hetzner's dedicated server firewall. +seealso: + - name: Firewall documentation + description: Hetzner's documentation on the stateless firewall for dedicated servers + link: https://docs.hetzner.com/robot/dedicated-server/firewall/ + - module: community.hrobot.firewall + description: Configure firewall. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + - community.hrobot.attributes.info_module + +attributes: + action_group: + version_added: 1.6.0 + +options: + server_ip: + description: + - The server's main IP address. + - Exactly one of I(server_ip) and I(server_number) must be specified. + - Note that Hetzner deprecated identifying the server's firewall by the server's main IP. + Using this option can thus stop working at any time in the future. Use I(server_number) instead. + type: str + server_number: + description: + - The server's number. + - Exactly one of I(server_ip) and I(server_number) must be specified. + type: int + version_added: 1.8.0 + wait_for_configured: + description: + - Whether to wait until the firewall has been successfully configured before + returning from the module. + - The API returns status C(in progress) when the firewall is currently + being configured. If this happens, the module will try again until + the status changes to C(active) or C(disabled). + - Please note that there is a request limit. If you have to do multiple + updates, it can be better to disable waiting, and regularly use + M(community.hrobot.firewall_info) to query status. + type: bool + default: true + wait_delay: + description: + - Delay to wait (in seconds) before checking again whether the firewall has + been configured. + type: int + default: 10 + timeout: + description: + - Timeout (in seconds) for waiting for firewall to be configured. + type: int + default: 180 +''' + +EXAMPLES = r''' +- name: Get firewall configuration for server with main IP 1.2.3.4 + community.hrobot.firewall_info: + hetzner_user: foo + hetzner_password: bar + server_ip: 1.2.3.4 + register: result + +- ansible.builtin.debug: + msg: "{{ result.firewall }}" +''' + +RETURN = r''' +firewall: + description: + - The firewall configuration. + type: dict + returned: success + contains: + port: + description: + - Switch port of firewall. + - C(main) or C(kvm). + type: str + sample: main + filter_ipv6: + description: + - Whether the firewall rules apply to IPv6 as well or not. + type: bool + sample: false + server_ip: + description: + - Server's main IP address. + type: str + sample: 1.2.3.4 + server_number: + description: + - Hetzner's internal server number. + type: int + sample: 12345 + status: + description: + - Status of the firewall. + - C(active) or C(disabled). + - Will be C(in process) if the firewall is currently updated, and + I(wait_for_configured) is set to C(false) or I(timeout) to a too small value. + type: str + sample: active + allowlist_hos: + description: + - Whether Hetzner services have access. + type: bool + sample: true + version_added: 1.2.0 + whitelist_hos: + description: + - Whether Hetzner services have access. + - Old name of return value C(allowlist_hos), will be removed eventually. + type: bool + sample: true + rules: + description: + - Firewall rules. + type: dict + contains: + input: + description: + - Input firewall rules. + type: list + elements: dict + contains: + name: + description: + - Name of the firewall rule. + type: str + sample: Allow HTTP access to server + ip_version: + description: + - Internet protocol version. + - No value means the rule applies both to IPv4 and IPv6. + type: str + sample: ipv4 + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + sample: 1.2.3.4/32 + dst_port: + description: + - Destination port or port range. + type: str + sample: "443" + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + sample: null + src_port: + description: + - Source port or port range. + type: str + sample: null + protocol: + description: + - Protocol above IP layer. + type: str + sample: tcp + tcp_flags: + description: + - TCP flags or logical combination of flags. + type: str + sample: null + action: + description: + - Action if rule matches. + - C(accept) or C(discard). + type: str + sample: accept + choices: + - accept + - discard + output: + description: + - Output firewall rules. + type: list + elements: dict + contains: + name: + description: + - Name of the firewall rule. + type: str + sample: Allow HTTP access to server + ip_version: + description: + - Internet protocol version. + - No value means the rule applies both to IPv4 and IPv6. + type: str + sample: ~ + dst_ip: + description: + - Destination IP address or subnet address. + - CIDR notation. + type: str + sample: 1.2.3.4/32 + dst_port: + description: + - Destination port or port range. + type: str + sample: "443" + src_ip: + description: + - Source IP address or subnet address. + - CIDR notation. + type: str + sample: null + src_port: + description: + - Source port or port range. + type: str + sample: null + protocol: + description: + - Protocol above IP layer. + type: str + sample: tcp + tcp_flags: + description: + - TCP flags or logical combination of flags. + type: str + sample: null + action: + description: + - Action if rule matches. + - C(accept) or C(discard). + type: str + sample: accept + choices: + - accept + - discard +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + ROBOT_DEFAULT_ARGUMENT_SPEC, + BASE_URL, + fetch_url_json, + fetch_url_json_with_retries, + CheckDoneTimeoutException, +) + + +def firewall_configured(result, error): + return result['firewall']['status'] != 'in process' + + +def main(): + argument_spec = dict( + server_ip=dict(type='str'), + server_number=dict(type='int'), + wait_for_configured=dict(type='bool', default=True), + wait_delay=dict(type='int', default=10), + timeout=dict(type='int', default=180), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + server_id = module.params['server_ip'] or module.params['server_number'] + + # https://robot.your-server.de/doc/webservice/en.html#get-firewall-server-ip + url = "{0}/firewall/{1}".format(BASE_URL, server_id) + if module.params['wait_for_configured']: + try: + result, error = fetch_url_json_with_retries( + module, + url, + check_done_callback=firewall_configured, + check_done_delay=module.params['wait_delay'], + check_done_timeout=module.params['timeout'], + ) + except CheckDoneTimeoutException as dummy: + module.fail_json(msg='Timeout while waiting for firewall to be configured.') + else: + result, error = fetch_url_json(module, url) + + firewall = result['firewall'] + firewall['allowlist_hos'] = firewall.get('whitelist_hos', False) + if not firewall.get('rules'): + firewall['rules'] = dict() + for ruleset in ['input']: + firewall['rules'][ruleset] = [] + + module.exit_json( + changed=False, + firewall=firewall, + ) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/reset.py b/ansible_collections/community/hrobot/plugins/modules/reset.py new file mode 100644 index 000000000..d367936e0 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/reset.py @@ -0,0 +1,150 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: reset +short_description: Reset a dedicated server +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Reset a dedicated server with a software or hardware reset, or by requesting a manual reset. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: none + +options: + server_number: + description: + - The server number of the server to reset. + type: int + required: true + reset_type: + description: + - How to reset the server. + - C(software) is a software reset. This should be similar to pressing Ctrl+Alt+Del on the keyboard. + - C(power) is a hardware reset similar to pressing the Power button. An ACPI signal is sent, and if the + server is configured correctly, this will trigger a regular shutdown. + - C(hardware) is a hardware reset similar to pressing the Restart button. The power is cycled for the server. + - C(manual) is a manual reset. This requests a technician to manually do the shutdown while looking at the + screen output. B(Be careful) and only use this when really necessary! + - Note that not every server supports every reset method! + type: str + required: true + choices: + - software + - hardware + - power + - manual +''' + +EXAMPLES = r''' +- name: Send ACPI signal to server to request controlled shutdown + community.hrobot.reset: + hetzner_user: foo + hetzner_password: bar + failover_ip: 1.2.3.4 + state: power + +- name: Make sure that the server supports manual reset + community.hrobot.reset: + hetzner_user: foo + hetzner_password: bar + server_number: 1234 + reset_type: manual + check_mode: true + +- name: Request a manual reset (by a technican) + community.hrobot.reset: + hetzner_user: foo + hetzner_password: bar + server_number: 1234 + reset_type: manual +''' + +RETURN = r''' # ''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +def main(): + argument_spec = dict( + server_number=dict(type='int', required=True), + reset_type=dict(type='str', required=True, choices=['software', 'hardware', 'power', 'manual']), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + server_number = module.params['server_number'] + reset_type = { + 'software': 'sw', + 'hardware': 'hw', + 'power': 'power', + 'manual': 'man', + }[module.params['reset_type']] + + if module.check_mode: + url = "{0}/reset/{1}".format(BASE_URL, server_number) + result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND', 'RESET_NOT_AVAILABLE']) + if not error and reset_type not in result['reset']['type']: + module.fail_json(msg='The chosen reset method is not supported for this server') + else: + headers = {"Content-type": "application/x-www-form-urlencoded"} + data = dict( + type=reset_type, + ) + url = "{0}/reset/{1}".format(BASE_URL, server_number) + result, error = fetch_url_json( + module, + url, + data=urlencode(data), + headers=headers, + method='POST', + accept_errors=['INVALID_INPUT', 'SERVER_NOT_FOUND', 'RESET_NOT_AVAILABLE', 'RESET_MANUAL_ACTIVE', 'RESET_FAILED'], + ) + if error and error == 'INVALID_INPUT': + module.fail_json(msg='The chosen reset method is not supported for this server') + if error: + if error == 'SERVER_NOT_FOUND': + module.fail_json(msg='This server does not exist, or you do not have access rights for it') + if error == 'RESET_NOT_AVAILABLE': + module.fail_json(msg='The server has no reset option available') + if error == 'RESET_MANUAL_ACTIVE': + module.fail_json(msg='A manual reset is already running') + if error == 'RESET_FAILED': + module.fail_json(msg='The reset failed due to an internal error at Hetzner') + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + + module.exit_json(changed=True) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/reverse_dns.py b/ansible_collections/community/hrobot/plugins/modules/reverse_dns.py new file mode 100644 index 000000000..200489217 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/reverse_dns.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: reverse_dns +short_description: Set or remove reverse DNS entry for IP +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Allows to set, update or remove a reverse DNS entry for an IP address. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot +notes: + - For the main IPv4 address of a server, deleting it actually sets it to a default hostname like + C(static.X.Y.Z.W.clients.your-server.de). This substitution (delete is replaced by changing to + this value) is done automatically by the API and results in the module not being idempotent + in this case. + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: none + +options: + ip: + description: + - The IP address to set or remove a reverse DNS entry for. + type: str + required: true + state: + description: + - Whether to set or update (C(present)) or delete (C(absent)) the reverse DNS entry for I(ip). + type: str + default: present + choices: + - present + - absent + value: + description: + - The reverse DNS entry for I(ip). + - Required if I(state=present). + type: str +''' + +EXAMPLES = r''' +- name: Set reverse DNS entry for 1.2.3.4 + community.hrobot.reverse_dns: + hetzner_user: foo + hetzner_password: bar + ip: 1.2.3.4 + value: foo.example.com + +- name: Remove reverse DNS entry for 2a01:f48:111:4221::1 + community.hrobot.reverse_dns: + hetzner_user: foo + hetzner_password: bar + ip: 2a01:f48:111:4221::1 + state: absent +''' + +RETURN = r''' # ''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +def main(): + argument_spec = dict( + ip=dict(type='str', required=True), + state=dict(type='str', choices=['present', 'absent'], default='present'), + value=dict(type='str'), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[('state', 'present', ['value'])], + ) + + ip = module.params['ip'] + state = module.params['state'] + value = module.params['value'] + + url = "{0}/rdns/{1}".format(BASE_URL, ip) + result, error = fetch_url_json(module, url, accept_errors=['IP_NOT_FOUND', 'RDNS_NOT_FOUND']) + if error == 'RDNS_NOT_FOUND': + current = None + elif error: + if error == 'IP_NOT_FOUND': + module.fail_json(msg='The IP address was not found') + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + else: + current = result['rdns']['ptr'] + + changed = False + expected = value if state == 'present' else None + + if current != expected: + changed = True + if not module.check_mode: + if expected is None: + fetch_url_json(module, url, method='DELETE', allow_empty_result=True) + else: + headers = {'Content-type': 'application/x-www-form-urlencoded'} + data = {'ptr': expected} + fetch_url_json(module, url, data=urlencode(data), headers=headers, method='POST') + + module.exit_json(changed=changed) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/server.py b/ansible_collections/community/hrobot/plugins/modules/server.py new file mode 100644 index 000000000..2a24986e3 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/server.py @@ -0,0 +1,274 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: server +short_description: Update server information +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Allows to update server information. + - Right now the API only supports updating the server's name. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: none + +options: + server_number: + description: + - The server number of the server to update. + type: int + required: true + server_name: + description: + - The server's name. + - If this option is not provided, it will not be adjusted. + type: str +''' + +EXAMPLES = r''' +- name: Set server's name to foo.example.com + community.hrobot.server: + hetzner_user: foo + hetzner_password: bar + server_number: 123 + server_name: foo.example.com +''' + +RETURN = r''' +server: + description: + - Information on the server. + returned: success + type: dict + contains: + server_ip: + description: + - The server's main IP address. + type: str + sample: 123.123.123.123 + returned: success + server_ipv6_net: + description: + - The server's main IPv6 network address. + type: str + sample: '2a01:f48:111:4221::' + returned: success + server_number: + description: + - The server's numeric ID. + type: int + sample: 321 + returned: success + server_name: + description: + - The user-defined server's name. + type: str + sample: server1 + returned: success + product: + description: + - The server product name. + type: str + sample: EQ 8 + returned: success + dc: + description: + - The data center the server is located in. + type: str + sample: NBG1-DC1 + returned: success + traffic: + description: + - Free traffic quota. + - C(unlimited) in case of unlimited traffic. + type: str + sample: 5 TB + returned: success + status: + description: + - Server status. + type: str + choices: + - ready + - in process + sample: ready + returned: success + cancelled: + description: + - Whether the server is cancelled. + type: bool + sample: false + returned: success + paid_until: + description: + - The date until the server has been paid. + type: str + sample: "2018-08-04" + returned: success + ip: + description: + - List of assigned single IP addresses. + type: list + elements: str + sample: + - 123.123.123.123 + returned: success + subnet: + description: + - List of assigned subnets. + type: list + elements: dict + sample: + - ip: '2a01:4f8:111:4221::' + mask: 64 + contains: + ip: + description: + - The first IP in the subnet. + type: str + sample: '2a01:4f8:111:4221::' + mask: + description: + - The masks bitlength. + type: str + sample: "64" + returned: success + reset: + description: + - Whether the server can be automatically reset. + type: bool + sample: true + returned: success + rescue: + description: + - Whether the rescue system is available. + type: bool + sample: false + returned: success + vnc: + description: + - Flag of VNC installation availability. + type: bool + sample: true + returned: success + windows: + description: + - Flag of Windows installation availability. + type: bool + sample: true + returned: success + plesk: + description: + - Flag of Plesk installation availability. + type: bool + sample: true + returned: success + cpanel: + description: + - Flag of cPanel installation availability. + type: bool + sample: true + returned: success + wol: + description: + - Flag of Wake On Lan availability. + type: bool + sample: true + returned: success + hot_swap: + description: + - Flag of Hot Swap availability. + type: bool + sample: true + returned: success + linked_storagebox: + description: + - Linked Storage Box ID. + type: int + sample: 12345 + returned: success +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +def main(): + argument_spec = dict( + server_number=dict(type='int', required=True), + server_name=dict(type='str'), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + server_number = module.params['server_number'] + server_name = module.params['server_name'] + + url = "{0}/server/{1}".format(BASE_URL, server_number) + server, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND']) + if error: + module.fail_json(msg='This server does not exist, or you do not have access rights for it') + + result = { + 'changed': False, + 'server': server['server'], + } + + update = {} + if server_name is not None: + if server_name != result['server']['server_name']: + update['server_name'] = server_name + + if update: + result['changed'] = True + if module.check_mode: + result['server'].update(update) + else: + headers = {"Content-type": "application/x-www-form-urlencoded"} + url = "{0}/server/{1}".format(BASE_URL, server_number) + server, error = fetch_url_json( + module, + url, + data=urlencode(update), + headers=headers, + method='POST', + accept_errors=['INVALID_INPUT'], + ) + if error: + module.fail_json(msg='The values to update were invalid ({0})'.format(module.jsonify(update))) + result['server'] = server['server'] + + module.exit_json(**result) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/server_info.py b/ansible_collections/community/hrobot/plugins/modules/server_info.py new file mode 100644 index 000000000..b3f6da11d --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/server_info.py @@ -0,0 +1,283 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: server_info +short_description: Query information on one or more servers +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Query information on one or more servers. +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + - community.hrobot.attributes.info_module + +attributes: + action_group: + version_added: 1.6.0 + +options: + server_number: + description: + - Limit result list to server with this number. + type: int + server_name: + description: + - Limit result list to servers of this name. + type: str + full_info: + description: + - Whether to provide full information for every server. + - Setting this to C(true) requires one REST call per server, + which is slow and reduces your rate limit. Use with care. + - When I(server_number) is specified, this option is set to C(true). + type: bool + default: false +''' + +EXAMPLES = r''' +- name: Query a list of all servers + community.hrobot.server_info: + hetzner_user: foo + hetzner_password: bar + register: result + +- name: Query a specific server + community.hrobot.server_info: + hetzner_user: foo + hetzner_password: bar + server_number: 23 + register: result + +- name: Output data on specific server + ansible.builtin.debug: + msg: "Server name: {{ result.servers[0].server_name }}" +''' + +RETURN = r''' +servers: + description: + - List of servers matching the provided options. + returned: success + type: list + elements: dict + contains: + server_ip: + description: + - The server's main IP address. + type: str + sample: 123.123.123.123 + returned: success + server_ipv6_net: + description: + - The server's main IPv6 network address. + type: str + sample: '2a01:f48:111:4221::' + returned: success + server_number: + description: + - The server's numeric ID. + type: int + sample: 321 + returned: success + server_name: + description: + - The user-defined server's name. + type: str + sample: server1 + returned: success + product: + description: + - The server product name. + type: str + sample: EQ 8 + returned: success + dc: + description: + - The data center the server is located in. + type: str + sample: NBG1-DC1 + returned: success + traffic: + description: + - Free traffic quota. + - C(unlimited) in case of unlimited traffic. + type: str + sample: 5 TB + returned: success + status: + description: + - Server status. + type: str + choices: + - ready + - in process + sample: ready + returned: success + cancelled: + description: + - Whether the server is cancelled. + type: bool + sample: false + returned: success + paid_until: + description: + - The date until the server has been paid. + type: str + sample: "2018-08-04" + returned: success + ip: + description: + - List of assigned single IP addresses. + type: list + elements: str + sample: + - 123.123.123.123 + returned: success + subnet: + description: + - List of assigned subnets. + type: list + elements: dict + sample: + - ip: '2a01:4f8:111:4221::' + mask: 64 + contains: + ip: + description: + - The first IP in the subnet. + type: str + sample: '2a01:4f8:111:4221::' + mask: + description: + - The masks bitlength. + type: str + sample: "64" + returned: success + reset: + description: + - Whether the server can be automatically reset. + type: bool + sample: true + returned: when I(full_info=true) + rescue: + description: + - Whether the rescue system is available. + type: bool + sample: false + returned: when I(full_info=true) + vnc: + description: + - Flag of VNC installation availability. + type: bool + sample: true + returned: when I(full_info=true) + windows: + description: + - Flag of Windows installation availability. + type: bool + sample: true + returned: when I(full_info=true) + plesk: + description: + - Flag of Plesk installation availability. + type: bool + sample: true + returned: when I(full_info=true) + cpanel: + description: + - Flag of cPanel installation availability. + type: bool + sample: true + returned: when I(full_info=true) + wol: + description: + - Flag of Wake On Lan availability. + type: bool + sample: true + returned: when I(full_info=true) + hot_swap: + description: + - Flag of Hot Swap availability. + type: bool + sample: true + returned: when I(full_info=true) + linked_storagebox: + description: + - Linked Storage Box ID. + type: int + sample: 12345 + returned: when I(full_info=true) +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +def main(): + argument_spec = dict( + server_number=dict(type='int'), + server_name=dict(type='str'), + full_info=dict(type='bool', default=False), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + server_number = module.params['server_number'] + server_name = module.params['server_name'] + full_info = module.params['full_info'] + + servers = [] + if server_number is not None: + server_numbers = [server_number] + else: + url = "{0}/server".format(BASE_URL) + result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND']) + server_numbers = [] + if not error: + for entry in result: + if server_name is not None: + if entry['server']['server_name'] != server_name: + continue + if full_info: + server_numbers.append(entry['server']['server_number']) + else: + servers.append(entry['server']) + + for server_number in server_numbers: + url = "{0}/server/{1}".format(BASE_URL, server_number) + result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND']) + if not error: + if server_name is not None: + if result['server']['server_name'] != server_name: + continue + servers.append(result['server']) + + module.exit_json( + changed=False, + servers=servers, + ) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/ssh_key.py b/ansible_collections/community/hrobot/plugins/modules/ssh_key.py new file mode 100644 index 000000000..2353514b9 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/ssh_key.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: ssh_key +short_description: Add, remove or update SSH key +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - Add, remove or update an SSH key stored in Hetzner's Robot. +seealso: + - module: community.hrobot.ssh_key_info + description: Query information on SSH keys +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + action_group: + version_added: 1.6.0 + check_mode: + support: full + diff_mode: + support: none + +options: + state: + description: + - Whether to make sure a public SSH key is present or absent. + - C(present) makes sure that the SSH key is available, and + potentially updates names for existing SHS public keys. + - C(absent) makes sure that the SSH key is not available. + The fingerprint or public key data is used for matching the + key. + required: true + type: str + choices: + - present + - absent + name: + description: + - The public key's name. + - Required if I(state=present), and ignored if I(state=absent). + type: str + fingerprint: + description: + - The MD5 fingerprint of the public SSH key to remove. + - One of I(public_key) and I(fingerprint) are required if I(state=absent). + type: str + public_key: + description: + - The public key data in OpenSSH format. + - "Example: C(ssh-rsa AAAAB3NzaC1yc+...)" + - One of I(public_key) and I(fingerprint) are required if I(state=absent). + - Required if I(state=present). + type: str +''' + +EXAMPLES = r''' +- name: Add an SSH key + community.hrobot.ssh_key: + hetzner_user: foo + hetzner_password: bar + state: present + name: newKey + public_key: ssh-rsa AAAAB3NzaC1yc+... + +- name: Remove a SSH key by fingerprint + community.hrobot.ssh_key: + hetzner_user: foo + hetzner_password: bar + state: absent + fingerprint: cb:8b:ef:a7:fe:04:87:3f:e5:55:cd:12:e3:e8:9f:99 +''' + +RETURN = r''' +fingerprint: + description: + - The MD5 fingerprint of the key. + - This is the value used to reference the SSH public key, for example in the M(community.hrobot.boot) module. + returned: success + type: str + sample: cb:8b:ef:a7:fe:04:87:3f:e5:55:cd:12:e3:e8:9f:99 +''' + +import base64 +import binascii +import re + +from ansible.module_utils.basic import AnsibleModule, AVAILABLE_HASH_ALGORITHMS +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +class FingerprintError(Exception): + pass + + +SPACE_RE = re.compile(' +') +FINGERPRINT_PART = re.compile('^[0-9a-f]{2}$') + + +def normalize_fingerprint(fingerprint, size=16): + if ':' in fingerprint: + fingerprint = fingerprint.split(':') + else: + fingerprint = [fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)] + if len(fingerprint) != size: + raise FingerprintError( + 'Fingerprint must consist of {0} 8-bit hex numbers: got {1} 8-bit hex numbers instead'.format(size, len(fingerprint))) + for i, part in enumerate(fingerprint): + new_part = part.lower() + if len(new_part) < 2: + new_part = '0{0}'.format(new_part) + if not FINGERPRINT_PART.match(new_part): + raise FingerprintError( + 'Fingerprint must consist of {0} 8-bit hex numbers: number {1} is invalid: "{2}"'.format(size, i + 1, part)) + fingerprint[i] = new_part + return ':'.join(fingerprint) + + +def extract_fingerprint(public_key, alg='md5', size=16): + try: + public_key = SPACE_RE.split(public_key.strip())[1] + except IndexError: + raise FingerprintError( + 'Error while extracting fingerprint from public key data: cannot split public key into at least two parts') + try: + public_key = base64.b64decode(public_key) + except (binascii.Error, TypeError) as exc: + raise FingerprintError( + 'Error while extracting fingerprint from public key data: {0}'.format(exc)) + try: + algorithm = AVAILABLE_HASH_ALGORITHMS[alg] + except KeyError: + raise FingerprintError( + 'Hash algorithm {0} is not available. Possibly running in FIPS mode.'.format(alg.upper())) + digest = algorithm() + digest.update(public_key) + return normalize_fingerprint(digest.hexdigest(), size=size) + + +def main(): + argument_spec = dict( + state=dict(type='str', required=True, choices=['present', 'absent']), + name=dict(type='str'), + fingerprint=dict(type='str'), + public_key=dict(type='str'), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[ + ('fingerprint', 'public_key'), + ], + required_if=[ + ('state', 'present', ['name', 'public_key']), + ('state', 'absent', ['fingerprint', 'public_key'], True), + ], + ) + + state = module.params['state'] + name = module.params['name'] + fingerprint = module.params['fingerprint'] + public_key = module.params['public_key'] + + try: + if fingerprint is not None: + fingerprint = normalize_fingerprint(fingerprint) + else: + fingerprint = extract_fingerprint(public_key) + except FingerprintError as exc: + module.fail_json(msg=to_native(exc)) + + url = "{0}/key/{1}".format(BASE_URL, fingerprint) + + # Remove key + if state == 'absent': + if module.check_mode: + dummy, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND']) + else: + dummy, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND'], method='DELETE', allow_empty_result=True) + if error == 'NOT_FOUND': + changed = False + elif error is not None: + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + else: + changed = True + module.exit_json(changed=changed, fingerprint=fingerprint) + + # Make sure key is present + result, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND']) + if error == 'NOT_FOUND': + changed = True + exists = False + elif error is not None: + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + else: + exists = True + changed = False + # The only thing we can update is the name + if result['key'].get('name') != name: + changed = True + + if changed and not module.check_mode: + data = { + 'name': name, + } + if not exists: + # Create key + data['data'] = ' '.join(SPACE_RE.split(public_key.strip())[:2]) + url = "{0}/key".format(BASE_URL) + # Update or create key + headers = {"Content-type": "application/x-www-form-urlencoded"} + result, dummy = fetch_url_json( + module, + url, + data=urlencode(data), + headers=headers, + method='POST', + ) + + module.exit_json(changed=changed, fingerprint=fingerprint) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/ssh_key_info.py b/ansible_collections/community/hrobot/plugins/modules/ssh_key_info.py new file mode 100644 index 000000000..69d4d8111 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/ssh_key_info.py @@ -0,0 +1,113 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: ssh_key_info +short_description: Query information on SSH keys +version_added: 1.2.0 +author: + - Felix Fontein (@felixfontein) +description: + - List information on all your SSH keys stored in Hetzner's Robot. +seealso: + - module: community.hrobot.ssh_key + description: Add, remove or update SSH key +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + - community.hrobot.attributes.info_module +attributes: + action_group: + version_added: 1.6.0 +''' + +EXAMPLES = r''' +- name: List all SSH keys + community.hrobot.ssh_key_info: + hetzner_user: foo + hetzner_password: bar + register: ssh_keys + +- name: Show how many keys were found + ansible.builtin.debug: + msg: "Found {{ ssh_keys.ssh_keys | length }} keys" +''' + +RETURN = r''' +ssh_keys: + description: + - The list of all SSH keys stored in Hetzner's Robot for your user. + returned: success + type: list + elements: dict + contains: + name: + description: + - The key's name shown in the UI. + type: str + sample: key1 + fingerprint: + description: + - The key's MD5 fingerprint. + type: str + sample: 56:29:99:a4:5d:ed:ac:95:c1:f5:88:82:90:5d:dd:10 + type: + description: + - The key's algorithm type. + type: str + sample: ECDSA + size: + description: + - The key's size in bits. + type: int + sample: 521 + data: + description: + - The key data in OpenSSH's format. + type: str + sample: ecdsa-sha2-nistp521 AAAAE2VjZHNh ... +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + fetch_url_json, +) + + +def main(): + argument_spec = dict() + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + url = "{0}/key".format(BASE_URL) + result, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND']) + if error == 'NOT_FOUND': + result = [] + elif error is not None: + raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover + + keys = [] + for key in result: + keys.append(key['key']) + + module.exit_json(changed=False, ssh_keys=keys) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover diff --git a/ansible_collections/community/hrobot/plugins/modules/v_switch.py b/ansible_collections/community/hrobot/plugins/modules/v_switch.py new file mode 100644 index 000000000..6035392a4 --- /dev/null +++ b/ansible_collections/community/hrobot/plugins/modules/v_switch.py @@ -0,0 +1,504 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Alexander Gil Casas <alexander.gilcasas@trustyou.net> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: v_switch +short_description: Manage Hetzner's vSwitch +version_added: 1.7.0 +author: + - Alexander Gil Casas (@pando85) +description: + - Manage Hetzner's vSwitch. +seealso: + - name: vSwitch documentation + description: Hetzner's documentation on vSwitch for connecting dedicated servers. + link: https://docs.hetzner.com/robot/dedicated-server/network/vswitch +extends_documentation_fragment: + - community.hrobot.robot + - community.hrobot.attributes + - community.hrobot.attributes.actiongroup_robot + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + vlan: + description: + - The vSwitch's VLAN ID. + - Range can be from 4000 to 4091. + - In order to identify a vSwitch both name and VLAN must match. If not, a new vSwitch will be created. + type: int + required: true + name: + description: + - The vSwitch's name. + - In order to identify a vSwitch both name and VLAN must match. If not, a new vSwitch will be created. + type: str + required: true + state: + description: + - State of the vSwitch. + - vSwitch is created if state is C(present), and deleted if state is C(absent). + - C(absent) just cancels the vSwitch at the end of the current day. + - When cancelling, you have to specify I(servers=[]) if you want to actively remove the servers in the vSwitch. + type: str + default: present + choices: [ present, absent ] + servers: + description: + - List of server identifiers (server's numeric ID or server's main IPv4 or IPv6). + - If servers is not specified, servers are not going to be deleted. + type: list + elements: str + wait: + description: + - Whether to wait until the vSwitch has been successfully configured before + determining what to do, and before returning from the module. + - The API returns status C(in process) when the vSwitch is currently + being set up in the servers. If this happens, the module will try again until + the status changes to C(ready) or server has been removed from vSwitch. + - Please note that if you disable wait while deleting and removing servers module + will fail with C(VSWITCH_IN_PROCESS) error. + type: bool + default: true + wait_delay: + description: + - Delay to wait (in seconds) before checking again whether the vSwitch servers has been configured. + type: int + default: 10 + timeout: + description: + - Timeout (in seconds) for waiting for vSwitch servers to be configured. + type: int + default: 180 +''' + +EXAMPLES = r''' +- name: Create vSwitch with VLAN 4010 and name foo + community.hrobot.v_switch: + hetzner_user: foo + hetzner_password: bar + vlan: 4010 + name: foo + +- name: Create vSwitch with VLAN 4020 and name foo with two servers + community.hrobot.v_switch: + hetzner_user: foo + hetzner_password: bar + vlan: 4010 + name: foo + servers: + - 123.123.123.123 + - 154323 +''' + +RETURN = r''' +v_switch: + description: + - Information on the vSwitch. + returned: success + type: dict + contains: + id: + description: + - The vSwitch's ID. + type: int + sample: 4321 + returned: success + name: + description: + - The vSwitch's name. + type: str + sample: 'my vSwitch' + returned: success + vlan: + description: + - The vSwitch's VLAN ID. + type: int + sample: 4000 + returned: success + cancelled: + description: + - Cancellation status. + type: bool + sample: false + returned: success + server: + description: + - The vSwitch's VLAN. + type: list + elements: dict + sample: + - server_ip: '123.123.123.123' + server_ipv6_net: '2a01:4f8:111:4221::' + server_number: 321 + status: 'ready' + contains: + server_ip: + description: + - The server's main IP address. + type: str + sample: '123.123.123.123' + server_ipv6_net: + description: + - The server's main IPv6 network address. + type: str + sample: '2a01:f48:111:4221::' + server_number: + description: + - The server's numeric ID. + type: int + sample: 321 + status: + description: + - Status of vSwitch for this server. + type: str + choices: + - ready + - in process + - failed + sample: 'ready' + returned: success + subnet: + description: + - List of assigned IP addresses. + type: list + elements: dict + sample: + - ip: '213.239.252.48' + mask: 29 + gateway: '213.239.252.49' + contains: + ip: + description: + - IP address. + type: str + sample: '213.239.252.48' + mask: + description: + - Subnet mask in CIDR notation. + type: int + sample: 29 + gateway: + description: + - Gateway of the subnet. + type: str + sample: '213.239.252.49' + returned: success + cloud_network: + description: + - List of assigned Cloud networks. + type: list + elements: dict + sample: + - id: 123 + ip: '10.0.2.0' + mask: 24 + gateway: '10.0.2.1' + contains: + id: + description: + - Cloud network ID. + type: int + sample: 123 + ip: + description: + - IP address. + type: str + sample: '10.0.2.0' + mask: + description: + - Subnet mask in CIDR notation. + type: int + sample: 24 + gateway: + description: + - Gateway. + type: str + sample: '10.0.2.1' + returned: success +''' + + +from datetime import datetime + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six.moves.urllib.parse import urlencode + +from ansible_collections.community.hrobot.plugins.module_utils.robot import ( + BASE_URL, + ROBOT_DEFAULT_ARGUMENT_SPEC, + get_x_www_form_urlenconded_dict_from_list, + fetch_url_json, + fetch_url_json_with_retries, + CheckDoneTimeoutException, +) + +V_SWITCH_BASE_URL = '{0}/vswitch'.format(BASE_URL) + + +def get_v_switch(module, id_, wait_condition=None): + url = '{0}/{1}'.format(V_SWITCH_BASE_URL, id_) + accept_errors = ['NOT_FOUND'] + if wait_condition: + try: + result, error = fetch_url_json_with_retries( + module, + url, + check_done_callback=wait_condition, + check_done_delay=module.params['wait_delay'], + check_done_timeout=module.params['timeout'], + accept_errors=accept_errors, + ) + except CheckDoneTimeoutException as dummy: + module.fail_json(msg='Timeout waiting vSwitch operation to finish') + else: + result, error = fetch_url_json( + module, + url, + accept_errors=accept_errors, + ) + + if error == 'NOT_FOUND': + module.fail_json(msg='vSwitch not found.') + + return result + + +def print_list(possible_list): + if isinstance(possible_list, list): + return [to_native(x) for x in possible_list] + + +def create_v_switch(module): + headers = {'Content-type': 'application/x-www-form-urlencoded'} + data = {'name': module.params['name'], 'vlan': module.params['vlan']} + result, error = fetch_url_json( + module, + V_SWITCH_BASE_URL, + data=urlencode(data), + headers=headers, + method='POST', + accept_errors=['INVALID_INPUT', 'VSWITCH_LIMIT_REACHED'], + ) + if error == 'INVALID_INPUT': + invalid_parameters = print_list(result['error']['invalid']) + module.fail_json(msg='vSwitch invalid parameter ({0})'.format(invalid_parameters)) + elif error == 'VSWITCH_LIMIT_REACHED': + module.fail_json(msg='The maximum count of vSwitches is reached') + + return result + + +def delete_v_switch(module, id_): + url = '{0}/{1}'.format(V_SWITCH_BASE_URL, id_) + headers = {'Content-type': 'application/x-www-form-urlencoded'} + data = {'cancellation_date': datetime.now().strftime('%y-%m-%d')} + result, error = fetch_url_json( + module, + url, + data=urlencode(data), + headers=headers, + method='DELETE', + accept_errors=['INVALID_INPUT', 'NOT_FOUND', 'CONFLICT'], + allow_empty_result=True, + ) + if error == 'INVALID_INPUT': + invalid_parameters = print_list(result['error']['invalid']) + module.fail_json(msg='vSwitch invalid parameter ({0})'.format(invalid_parameters)) + elif error == 'NOT_FOUND': + module.fail_json(msg='vSwitch not found to delete') + elif error == 'CONFLICT': + module.fail_json(msg='The vSwitch is already cancelled') + + return result + + +def is_all_servers_ready(result, dummy): + return all(server['status'] == 'ready' for server in result['server']) + + +def add_servers(module, id_, servers): + url = '{0}/{1}/server'.format(V_SWITCH_BASE_URL, id_) + headers = {'Content-type': 'application/x-www-form-urlencoded'} + data = get_x_www_form_urlenconded_dict_from_list('server', servers) + result, error = fetch_url_json( + module, + url, + data=urlencode(data), + headers=headers, + method='POST', + # TODO: missing NOT_FOUND, VSWITCH_NOT_AVAILABLE, VSWITCH_PER_SERVER_LIMIT_REACHED + accept_errors=[ + 'INVALID_INPUT', + 'SERVER_NOT_FOUND', + 'VSWITCH_VLAN_NOT_UNIQUE', + 'VSWITCH_IN_PROCESS', + 'VSWITCH_SERVER_LIMIT_REACHED', + ], + allow_empty_result=True, + allowed_empty_result_status_codes=(201,), + ) + if error == 'INVALID_INPUT': + invalid_parameters = print_list(result['error']['invalid']) + module.fail_json(msg='Invalid parameter adding server ({0})'.format(invalid_parameters)) + elif error == 'SERVER_NOT_FOUND': + # information about which servers are failing is only there + module.fail_json(msg=result['error']['message']) + elif error == 'VSWITCH_VLAN_NOT_UNIQUE': + # information about which servers are failing is only there + module.fail_json(msg=result['error']['message']) + elif error == 'VSWITCH_IN_PROCESS': + module.fail_json(msg='There is a update running, therefore the vswitch can not be updated') + elif error == 'VSWITCH_SERVER_LIMIT_REACHED': + module.fail_json(msg='The maximum number of servers is reached for this vSwitch') + + # TODO: add and delete with `wait=false` + wait_condition = is_all_servers_ready if module.params['wait'] else None + return get_v_switch(module, id_, wait_condition) + + +def delete_servers(module, id_, servers): + url = '{0}/{1}/server'.format(V_SWITCH_BASE_URL, id_) + headers = {'Content-type': 'application/x-www-form-urlencoded'} + data = get_x_www_form_urlenconded_dict_from_list('server', servers) + result, error = fetch_url_json( + module, + url, + data=urlencode(data), + headers=headers, + method='DELETE', + # TODO: missing INVALID_INPUT, NOT_FOUND + accept_errors=['SERVER_NOT_FOUND', 'VSWITCH_IN_PROCESS'], + allow_empty_result=True, + ) + if error == 'SERVER_NOT_FOUND': + # information about which servers are failing is only there + module.fail_json(msg=result['error']['message']) + elif error == 'VSWITCH_IN_PROCESS': + module.fail_json(msg='There is a update running, therefore the vswitch can not be updated') + + wait_condition = is_all_servers_ready if module.params['wait'] else None + return get_v_switch(module, id_, wait_condition) + + +def get_servers_to_delete(current_servers, desired_servers): + return [ + server['server_ip'] + for server in current_servers + if server['server_ip'] not in desired_servers + and server['server_ipv6_net'] not in desired_servers + and str(server['server_number']) not in desired_servers + ] + + +def get_servers_to_add(current_servers, desired_servers): + current_ids = [str(server['server_number']) for server in current_servers] + current_ips = [server['server_ip'] for server in current_servers] + current_ipv6s = [server['server_ipv6_net'] for server in current_servers] + + return [ + server + for server in desired_servers + if server not in current_ips and server not in current_ids and server not in current_ipv6s + ] + + +def set_desired_servers(module, id_): + v_switch = get_v_switch(module, id_) + changed = False + + if module.params['servers'] is None: + return (v_switch, changed) + + servers_to_delete = get_servers_to_delete(v_switch['server'], module.params['servers']) + if servers_to_delete: + if not module.check_mode: + v_switch = delete_servers(module, id_, servers_to_delete) + changed = True + servers_to_add = get_servers_to_add(v_switch['server'], module.params['servers']) + if servers_to_add: + if not module.check_mode: + v_switch = add_servers(module, id_, servers_to_add) + changed = True + return (v_switch, changed) + + +def main(): + argument_spec = dict( + vlan=dict(type='int', required=True), + name=dict(type='str', required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + servers=dict(type='list', elements='str'), + wait=dict(type='bool', default=True), + wait_delay=dict(type='int', default=10), + timeout=dict(type='int', default=180), + ) + argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + v_switches, error = fetch_url_json(module, V_SWITCH_BASE_URL, accept_errors=['UNAUTHORIZED']) + + if error: + module.fail_json(msg='Please check your current user and password configuration') + + matched_v_switches = [ + v + for v in v_switches + if v['name'] == module.params['name'] and v['vlan'] == module.params['vlan'] + ] + non_cancelled_v_switches = [m for m in matched_v_switches if m['cancelled'] is False] + result = {'changed': False} + + if len(non_cancelled_v_switches) > 1: + module.fail_json( + msg='Multiple vSwitches with same name and VLAN ID in non cancelled status. Clean it.' + ) + + elif len(non_cancelled_v_switches) == 1: + id_ = non_cancelled_v_switches[0]['id'] + v_switch, changed = set_desired_servers(module, id_) + if changed: + result['changed'] = True + + if module.params['state'] == 'present': + result['v_switch'] = v_switch + elif module.params['state'] == 'absent': + if not module.check_mode: + delete_v_switch(module, id_) + result['changed'] = True + else: + # not reachable + raise NotImplementedError + else: + if module.params['state'] == 'present': + result['changed'] = True + if not module.check_mode: + v_switch = create_v_switch(module) + if module.params['servers']: + result['v_switch'] = add_servers(module, v_switch['id'], module.params['servers']) + else: + result['v_switch'] = v_switch + + module.exit_json(**result) + + +if __name__ == '__main__': # pragma: no cover + main() # pragma: no cover |