#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2015, Björn Andersson # Copyright (c) 2021, Ansible Project # Copyright (c) 2021, Abhijeet Kasurde # 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_config short_description: Manage SSH config for user version_added: '2.0.0' description: - Configures SSH hosts with special C(IdentityFile)s and hostnames. author: - Björn Andersson (@gaqzi) - Abhijeet Kasurde (@Akasurde) extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: state: description: - Whether a host entry should exist or not. default: present choices: [ 'present', 'absent' ] type: str user: description: - Which user account this configuration file belongs to. - If none given and O(ssh_config_file) is not specified, C(/etc/ssh/ssh_config) is used. - If a user is given, C(~/.ssh/config) is used. - Mutually exclusive with O(ssh_config_file). type: str group: description: - Which group this configuration file belongs to. - If none given, O(user) is used. type: str host: description: - The endpoint this configuration is valid for. - Can be an actual address on the internet, an alias that will connect to the value of O(hostname), or V(*) to match all hosts. - If host is V(*) it is best to place it as the last host since ssh goes with the first matching option. This will make sure that any other host options take precedence over the V(*) host. required: true type: str hostname: description: - The actual host to connect to when connecting to the host defined. type: str port: description: - The actual port to connect to when connecting to the host defined. type: str remote_user: description: - Specifies the user to log in as. type: str identity_file: description: - The path to an identity file (SSH private key) that will be used when connecting to this host. - File need to exist and have mode V(0600) to be valid. type: path user_known_hosts_file: description: - Sets the user known hosts file option. type: str strict_host_key_checking: description: - Whether to strictly check the host key when doing connections to the remote host. choices: [ 'yes', 'no', 'ask' ] type: str proxycommand: description: - Sets the C(ProxyCommand) option. - Mutually exclusive with O(proxyjump). type: str proxyjump: description: - Sets the C(ProxyJump) option. - Mutually exclusive with O(proxycommand). type: str version_added: 6.5.0 forward_agent: description: - Sets the C(ForwardAgent) option. type: bool version_added: 4.0.0 ssh_config_file: description: - SSH config file. - If O(user) and this option are not specified, C(/etc/ssh/ssh_config) is used. - Mutually exclusive with O(user). type: path host_key_algorithms: description: - Sets the C(HostKeyAlgorithms) option. type: str version_added: 6.1.0 controlmaster: description: - Sets the C(ControlMaster) option. choices: [ 'yes', 'no', 'ask', 'auto', 'autoask' ] type: str version_added: 8.1.0 controlpath: description: - Sets the C(ControlPath) option. type: str version_added: 8.1.0 controlpersist: description: - Sets the C(ControlPersist) option. type: str version_added: 8.1.0 requirements: - paramiko ''' EXAMPLES = r''' - name: Add a host in the configuration community.general.ssh_config: user: akasurde host: "example.com" hostname: "github.com" identity_file: "/home/akasurde/.ssh/id_rsa" port: '2223' state: present - name: Delete a host from the configuration community.general.ssh_config: ssh_config_file: "{{ ssh_config_test }}" host: "example.com" state: absent ''' RETURN = r''' hosts_added: description: A list of host added. returned: success type: list sample: ["example.com"] hosts_removed: description: A list of host removed. returned: success type: list sample: ["example.com"] hosts_changed: description: A list of host changed. returned: success type: list sample: ["example.com"] hosts_change_diff: description: A list of host diff changes. returned: on change type: list sample: [ { "example.com": { "new": { "hostname": "github.com", "identityfile": ["/tmp/test_ssh_config/fake_id_rsa"], "port": "2224" }, "old": { "hostname": "github.com", "identityfile": ["/tmp/test_ssh_config/fake_id_rsa"], "port": "2224" } } } ] ''' import os from copy import deepcopy from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.general.plugins.module_utils._stormssh import ConfigParser, HAS_PARAMIKO, PARAMIKO_IMPORT_ERROR from ansible_collections.community.general.plugins.module_utils.ssh import determine_config_file def convert_bool(value): if value is True: return 'yes' if value is False: return 'no' return None def fix_bool_str(value): if value == 'True': return 'yes' if value == 'False': return 'no' return value class SSHConfig(object): def __init__(self, module): self.module = module if not HAS_PARAMIKO: module.fail_json(msg=missing_required_lib('PARAMIKO'), exception=PARAMIKO_IMPORT_ERROR) self.params = module.params self.user = self.params.get('user') self.group = self.params.get('group') or self.user self.host = self.params.get('host') self.config_file = self.params.get('ssh_config_file') self.identity_file = self.params['identity_file'] self.check_ssh_config_path() try: self.config = ConfigParser(self.config_file) except FileNotFoundError: self.module.fail_json(msg="Failed to find %s" % self.config_file) self.config.load() def check_ssh_config_path(self): self.config_file = determine_config_file(self.user, self.config_file) # See if the identity file exists or not, relative to the config file if os.path.exists(self.config_file) and self.identity_file is not None: dirname = os.path.dirname(self.config_file) self.identity_file = os.path.join(dirname, self.identity_file) if not os.path.exists(self.identity_file): self.module.fail_json(msg='IdentityFile %s does not exist' % self.params['identity_file']) def ensure_state(self): hosts_result = self.config.search_host(self.host) state = self.params['state'] args = dict( hostname=self.params.get('hostname'), port=self.params.get('port'), identity_file=self.params.get('identity_file'), user=self.params.get('remote_user'), strict_host_key_checking=self.params.get('strict_host_key_checking'), user_known_hosts_file=self.params.get('user_known_hosts_file'), proxycommand=self.params.get('proxycommand'), proxyjump=self.params.get('proxyjump'), host_key_algorithms=self.params.get('host_key_algorithms'), forward_agent=convert_bool(self.params.get('forward_agent')), controlmaster=self.params.get('controlmaster'), controlpath=self.params.get('controlpath'), controlpersist=fix_bool_str(self.params.get('controlpersist')), ) config_changed = False hosts_changed = [] hosts_change_diff = [] hosts_removed = [] hosts_added = [] hosts_result = [host for host in hosts_result if host['host'] == self.host] if hosts_result: for host in hosts_result: if state == 'absent': # Delete host from the configuration config_changed = True hosts_removed.append(host['host']) self.config.delete_host(host['host']) else: # Update host in the configuration changed, options = self.change_host(host['options'], **args) if changed: config_changed = True self.config.update_host(host['host'], options) hosts_changed.append(host['host']) hosts_change_diff.append({ host['host']: { 'old': host['options'], 'new': options, } }) elif state == 'present': changed, options = self.change_host(dict(), **args) if changed: config_changed = True hosts_added.append(self.host) self.config.add_host(self.host, options) if config_changed and not self.module.check_mode: try: self.config.write_to_ssh_config() except PermissionError as perm_exec: self.module.fail_json( msg="Failed to write to %s due to permission issue: %s" % (self.config_file, to_native(perm_exec))) # Make sure we set the permission perm_mode = '0600' if self.config_file == '/etc/ssh/ssh_config': perm_mode = '0644' self.module.set_mode_if_different(self.config_file, perm_mode, False) # Make sure the file is owned by the right user and group self.module.set_owner_if_different(self.config_file, self.user, False) self.module.set_group_if_different(self.config_file, self.group, False) self.module.exit_json(changed=config_changed, hosts_changed=hosts_changed, hosts_removed=hosts_removed, hosts_change_diff=hosts_change_diff, hosts_added=hosts_added) @staticmethod def change_host(options, **kwargs): options = deepcopy(options) changed = False for k, v in kwargs.items(): if '_' in k: k = k.replace('_', '') if not v: if options.get(k): del options[k] changed = True elif options.get(k) != v and not (isinstance(options.get(k), list) and v in options.get(k)): options[k] = v changed = True return changed, options def main(): module = AnsibleModule( argument_spec=dict( group=dict(default=None, type='str'), host=dict(type='str', required=True), hostname=dict(type='str'), host_key_algorithms=dict(type='str', no_log=False), identity_file=dict(type='path'), port=dict(type='str'), proxycommand=dict(type='str', default=None), proxyjump=dict(type='str', default=None), forward_agent=dict(type='bool'), remote_user=dict(type='str'), ssh_config_file=dict(default=None, type='path'), state=dict(type='str', default='present', choices=['present', 'absent']), strict_host_key_checking=dict( type='str', default=None, choices=['yes', 'no', 'ask'] ), controlmaster=dict(type='str', default=None, choices=['yes', 'no', 'ask', 'auto', 'autoask']), controlpath=dict(type='str', default=None), controlpersist=dict(type='str', default=None), user=dict(default=None, type='str'), user_known_hosts_file=dict(type='str', default=None), ), supports_check_mode=True, mutually_exclusive=[ ['user', 'ssh_config_file'], ['proxycommand', 'proxyjump'], ], ) ssh_config_obj = SSHConfig(module) ssh_config_obj.ensure_state() if __name__ == '__main__': main()