├── test ├── library │ └── ssh_config.py ├── test.yml ├── Vagrantfile └── roles │ └── normal-user │ └── tasks │ ├── _assert.yml │ └── main.yml ├── meta └── main.yml ├── CHANGELOG.md ├── README.md └── library └── ssh_config.py /test/library/ssh_config.py: -------------------------------------------------------------------------------- 1 | ../../library/ssh_config.py -------------------------------------------------------------------------------- /test/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: ssh_config 4 | hosts: all 5 | roles: 6 | - normal-user 7 | -------------------------------------------------------------------------------- /test/Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure('2') do |config| 2 | config.vm.box = 'box-cutter/centos71' 3 | config.vm.hostname = 'ssh-config' 4 | 5 | config.vm.provision 'ansible' do |ansible| 6 | ansible.playbook = 'test.yml' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | author: Björn Andersson 4 | description: A module for Ansible for configuring ssh configuration files. 5 | license: LGPL 6 | min_ansible_version: 1.8 7 | categories: 8 | - system 9 | dependencies: [] 10 | -------------------------------------------------------------------------------- /test/roles/normal-user/tasks/_assert.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: register shell output 4 | shell: cat ~vagrant/.ssh/config 5 | register: config 6 | changed_when: false 7 | 8 | - assert: 9 | that: 10 | - "'{{ option|lower }} {{ value }}' in config.stdout|lower" 11 | 12 | - name: reset .ssh/config 13 | file: > 14 | path=~vagrant/.ssh/config 15 | state=absent 16 | changed_when: false 17 | -------------------------------------------------------------------------------- /test/roles/normal-user/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Test port number 4 | ssh_config: > 5 | user=vagrant 6 | host=example.com 7 | port=2200 8 | - include: _assert.yml option=port value=2200 9 | 10 | - name: Test user 11 | ssh_config: > 12 | user=vagrant 13 | host=example.com 14 | remote_user=gaqzi 15 | - include: _assert.yml option=user value=gaqzi 16 | 17 | - name: Test hostname 18 | ssh_config: > 19 | user=vagrant 20 | host=example.com 21 | hostname=test.example.com 22 | - include: _assert.yml option=hostname value=test.example.com 23 | 24 | - name: Test identity file 25 | ssh_config: > 26 | user=vagrant 27 | host=example.com 28 | identity_file=id_rsa.fake 29 | - include: _assert.yml option=identityfile value=id_rsa.fake 30 | 31 | - name: create fake identity file (1/4) 32 | file: > 33 | path=/home/vagrant/.ssh/id_rsa.fake 34 | mode=0600 35 | state=touch 36 | changed_when: false 37 | 38 | - name: Test identity file has not changed - initial setup (2/4) 39 | ssh_config: > 40 | user=vagrant 41 | host=example.com 42 | identity_file=id_rsa.fake 43 | 44 | - name: Test identity file has not changed - change to same value (3/4) 45 | ssh_config: > 46 | user=vagrant 47 | host=example.com 48 | identity_file=id_rsa.fake 49 | register: identityfile 50 | 51 | - name: assert identity file didn't change when it didn't (4/4) 52 | assert: 53 | that: 54 | - identityfile['changed'] == False 55 | 56 | - name: Test user known hosts file 57 | ssh_config: > 58 | user=vagrant 59 | host=example.com 60 | user_known_hosts_file=/dev/null 61 | - include: _assert.yml option=userknownhostsfile value=/dev/null 62 | 63 | - name: Test strict host key checking 64 | ssh_config: > 65 | user=vagrant 66 | host=example.com 67 | strict_host_key_checking=yes 68 | - include: _assert.yml option=stricthostkeychecking value=yes 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.4.0] - 2016-02-14 4 | 5 | - Don't mark list values as always changed. 6 | For example IdentityFile was always being marked as changed. 7 | 8 | Thanks to @grypyrg on GitHub for the patch! 9 | 10 | ## [0.3.0] - 2015-11-03 11 | ### Changed 12 | 13 | - Moved the module to `library/` to allow for easy install as a role 14 | in Ansible Galaxy. 15 | 16 | - Submitted to Ansible Galaxy which was suggested by @MartinNowak on GitHub 17 | 18 | ## [0.2.0] - 2015-08-19 19 | ### Changed 20 | 21 | - Convert all keywords to CamelCase in the SSH config file. 22 | 23 | NOTE: If you get the new version of this plugin it'll change all the current 24 | keys to conform. 25 | (@bwaldvogel on GitHub) 26 | - Allow SSH to do home directory expansion for `identity_file` 27 | 28 | When providing a value to `identity_file` such as: 29 | 30 | > ~/.ssh/foo 31 | 32 | the path name should be passed as-is for writing to the template, since ssh 33 | itself is smart enough to perform home directory expansion. 34 | (@conorsch on GitHub) 35 | ## [0.1.0] - 2015-02-15 36 | ### Added 37 | - Remove options that are not present in host definition. 38 | E.g. if the `user` option is set in the config file and is not 39 | set in ansible then it will be removed on the nextansible run. 40 | (@z38 on GitHub) 41 | - Allow the config file for the root user to be set. 42 | Previous versions assumed that the default user was root and that the 43 | config file was `/etc/ssh/ssh_config`. 44 | 45 | It has now been changed so that 46 | if the user is unset then the config file will be `/etc/ssh/ssh_config` 47 | and if `root` is set then the config file in root's home directory will 48 | be used. 49 | (@z38) 50 | - New options: 51 | - `remote_user` - @z38 52 | - `user_known_hosts_file` - @gaqzi 53 | - `strict_host_key_checking` - @gaqzi 54 | 55 | ## [unversioned initial release] - 2013-11-23 56 | 57 | [0.4.0]: https://github.com/gaqzi/ansible-ssh-config/compare/v0.3.0...v0.4.0 58 | [0.3.0]: https://github.com/gaqzi/ansible-ssh-config/compare/v0.2.0...v0.3.0 59 | [0.2.0]: https://github.com/gaqzi/ansible-ssh-config/compare/v0.1.0...v0.2.0 60 | [0.1.0]: https://github.com/gaqzi/ansible-ssh-config/compare/96b7e80e71a4199ff4c5daa4b542adbd46f26a70...v0.1.0 61 | [unversioned initial release]: https://github.com/gaqzi/ansible-ssh-config/commit/96b7e80e71a4199ff4c5daa4b542adbd46f26a70 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ansible-ssh-config 2 | ================== 3 | 4 | THIS MODULE HAS MIGRATED TO https://github.com/ansible-collections/community.general/blob/main/plugins/modules/system/ssh_config.py 5 | Use the community version instead. 6 | 7 | A module for Ansible for configuring ssh configuration files. 8 | 9 | # Why? 10 | 11 | We have several libraries that carry shared functionality between 12 | projects at work. These libraries are on GitHub and they're in their 13 | own repo. Our deploy users don't have access to every single repo but 14 | only the ones they need to deploy a specific project. 15 | 16 | To manage this we have added in fake hostnames to our ~/.ssh/config 17 | files on the line of: 18 | 19 | ``` 20 | Host: internal-lib.github.com 21 | Hostname: github.com 22 | IdentityFile: id_rsa.internal-lib 23 | ``` 24 | 25 | When I started out with Ansible I tried just adding in our lines 26 | with [lineinfile], but it didn't work out for me since several lines 27 | needed to be added. 28 | 29 | # Usage 30 | 31 | The usage is fairly straightforward and it handles the normal use 32 | cases of adding, changing and removing hosts from your config file. 33 | 34 | ```yaml 35 | - name: Add internal-lib.github.com to ssh config 36 | ssh_config: host=internal-lib.github.com hostname=github.com 37 | identity_file=id_rsa.internal-lib port=222 state=present 38 | - name: Remove old-internal-lib.github.com from ssh config 39 | ssh_config: host=old-internal-lib.github.com state=absent 40 | ``` 41 | 42 | For the full set of options please look at the top of the module file. 43 | 44 | # Installation 45 | 46 | **Note**: The module needs to be installed into your library folder for 47 | Ansible to pick it up. 48 | 49 | ## Requirements file 50 | 51 | Add the following line to your `requirements.yml`: 52 | 53 | ```yaml 54 | - src: gaqzi.ssh-config 55 | path: library/ 56 | ``` 57 | 58 | ## Ansible Galaxy 59 | Alternatively install it from [Ansible Galaxy] by doing: 60 | 61 | ```shell 62 | $ ansible-galaxy install gaqzi.ssh-config -p library/ 63 | ``` 64 | 65 | Your directory structure should then look like this: 66 | 67 | ``` 68 | . 69 | ├── library 70 | │ └── gaqzi.ssh-config 71 | │ ├── CHANGELOG.md 72 | │ ├── library 73 | │ │ └── ssh_config.py 74 | │ ├── meta 75 | │ │ └── main.yml 76 | │ └── README.md 77 | └── site.yml 78 | ``` 79 | 80 | ## Manual 81 | Copy `ssh_config` into the library directory at the root of your Playbook. 82 | 83 | ``` 84 | . 85 | ├── library 86 | │ └── ssh_config 87 | └── site.yml 88 | ``` 89 | 90 | # Credits 91 | 92 | For managing the config files I blatantly copied `ConfigParser` 93 | from [stormssh] and [paramiko] which implemented all the functionality, 94 | but since I want to keep everything in one file to be easily 95 | reusable/shareable with Ansible we ended up here. 96 | 97 | [lineinfile]: http://www.ansibleworks.com/docs/modules.html#lineinfile 98 | [stormssh]: https://github.com/emre/storm/ 99 | [paramiko]: https://github.com/paramiko/paramiko 100 | [Ansible Galaxy]: https://galaxy.ansible.com/ 101 | -------------------------------------------------------------------------------- /library/ssh_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __version__ = '0.3.0' 5 | 6 | DOCUMENTATION = ''' 7 | --- 8 | module: ssh_config 9 | short_description: Manage a users ssh config 10 | description: 11 | - Configures ssh hosts with special IdentityFiles and hostnames 12 | options: 13 | state: 14 | description: 15 | - Whether a host entry should exist or not 16 | default: present 17 | choices: [ 'present', 'absent' ] 18 | user: 19 | description: 20 | - Which user account this configuration file belongs to. 21 | If none given /etc/ssh/ssh_config. If a user is given then 22 | `~/.ssh/config`. 23 | default: root 24 | choices: [] 25 | host: 26 | description: 27 | - The endpoint this configuration is valid for. Can be an actual 28 | address on the internet or an alias that will connect to the value 29 | of `hostname`. 30 | required: true 31 | choices: [] 32 | hostname: 33 | description: 34 | - The actual host to connect to when connecting to the host defined. 35 | choices: [] 36 | port: 37 | description: 38 | - The actual port to connect to when connecting to the host defined. 39 | required: false 40 | remote_user: 41 | description: 42 | - Specifies the user to log in as. 43 | choices: [] 44 | identity_file: 45 | description: 46 | - The path to an identitity file (ssh private) that will be used 47 | when connecting to this host. 48 | File need to exist and be 0600 to be valid. 49 | choices: [] 50 | user_known_hosts_file: 51 | description: 52 | - Sets the user known hosts file option 53 | strict_host_key_checking: 54 | description: 55 | - Whether to strictly check the host key when doing connections to the remote host 56 | choices: ['yes', 'no', 'ask'] 57 | proxycommand: 58 | description: 59 | - Sets the ProxyCommand option. 60 | required: false 61 | ''' 62 | 63 | EXAMPLES = ''' 64 | --- 65 | - config: 66 | user=deploy 67 | host=internal-library.github.com 68 | hostname=github.com 69 | identity_file=id_rsa.internal-library 70 | state=present 71 | - config: 72 | user=deploy 73 | host=old-internal.github.com 74 | remote_user=git 75 | state=absent 76 | - config: 77 | user=deploy 78 | host=old-internal.github.com 79 | port=2222 80 | ''' 81 | 82 | # The following block of code is part of paramiko. 83 | 84 | # Copyright (C) 2006-2007 Robey Pointer 85 | # Copyright (C) 2012 Olle Lundberg 86 | # 87 | # Paramiko is free software; you can redistribute it and/or modify it under the 88 | # terms of the GNU Lesser General Public License as published by the Free 89 | # Software Foundation; either version 2.1 of the License, or (at your option) 90 | # any later version. 91 | # 92 | # Paramiko is distributed in the hope that it will be useful, but WITHOUT ANY 93 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 94 | # A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 95 | # details. 96 | # 97 | # You should have received a copy of the GNU Lesser General Public License 98 | # along with Paramiko; if not, write to the Free Software Foundation, Inc., 99 | # 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. 100 | 101 | """ 102 | L{SSHConfig}. 103 | """ 104 | 105 | import copy 106 | import fnmatch 107 | import os 108 | import re 109 | import socket 110 | 111 | SSH_PORT = 22 112 | proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I) 113 | 114 | 115 | SSH_KEYWORDS = ( 116 | 'AddressFamily', 117 | 'BatchMode', 118 | 'BindAddress', 119 | 'ChallengeResponseAuthentication', 120 | 'CheckHostIP', 121 | 'Cipher', 122 | 'Ciphers', 123 | 'ClearAllForwardings', 124 | 'Compression', 125 | 'CompressionLevel', 126 | 'ConnectTimeout', 127 | 'ConnectionAttempts', 128 | 'ControlMaster', 129 | 'ControlPath', 130 | 'ControlPersist', 131 | 'DynamicForward', 132 | 'EnableSSHKeysign', 133 | 'EscapeChar', 134 | 'ExitOnForwardFailure', 135 | 'ForwardAgent', 136 | 'ForwardX11', 137 | 'ForwardX11Timeout', 138 | 'ForwardX11Trusted', 139 | 'GSSAPIAuthentication', 140 | 'GSSAPIClientIdentity', 141 | 'GSSAPIDelegateCredentials', 142 | 'GSSAPIKeyExchange', 143 | 'GSSAPIRenewalForcesRekey', 144 | 'GSSAPIServerIdentity', 145 | 'GSSAPITrustDNS', 146 | 'GSSAPITrustDns', 147 | 'GatewayPorts', 148 | 'GlobalKnownHostsFile', 149 | 'HashKnownHosts', 150 | 'HostKeyAlgorithms', 151 | 'HostKeyAlias', 152 | 'HostName', 153 | 'HostbasedAuthentication', 154 | 'IPQoS', 155 | 'IdentitiesOnly', 156 | 'IdentityFile', 157 | 'KbdInteractiveAuthentication', 158 | 'KbdInteractiveDevices', 159 | 'KexAlgorithms', 160 | 'LocalCommand', 161 | 'LocalForward', 162 | 'LogLevel', 163 | 'MACs', 164 | 'NoHostAuthenticationForLocalhost', 165 | 'NumberOfPasswordPrompts', 166 | 'PKCS11Provider', 167 | 'PasswordAuthentication', 168 | 'PermitLocalCommand', 169 | 'Port', 170 | 'PreferredAuthentications', 171 | 'Protocol', 172 | 'ProxyCommand', 173 | 'PubkeyAuthentication', 174 | 'RSAAuthentication', 175 | 'RekeyLimit', 176 | 'RemoteForward', 177 | 'RequestTTY', 178 | 'RhostsRSAAuthentication', 179 | 'SendEnv', 180 | 'ServerAliveCountMax', 181 | 'ServerAliveInterval', 182 | 'SmartcardDevice', 183 | 'StrictHostKeyChecking', 184 | 'TCPKeepAlive', 185 | 'Tunnel', 186 | 'TunnelDevice', 187 | 'UseBlacklistedKeys', 188 | 'UsePrivilegedPort', 189 | 'User', 190 | 'UserKnownHostsFile', 191 | 'VerifyHostKeyDNS', 192 | 'VisualHostKey', 193 | 'XAuthLocation' 194 | ) 195 | 196 | 197 | class LazyFqdn(object): 198 | """ 199 | Returns the host's fqdn on request as string. 200 | """ 201 | 202 | def __init__(self, config, host=None): 203 | self.fqdn = None 204 | self.config = config 205 | self.host = host 206 | 207 | def __str__(self): 208 | if self.fqdn is None: 209 | # 210 | # If the SSH config contains AddressFamily, use that when 211 | # determining the local host's FQDN. Using socket.getfqdn() from 212 | # the standard library is the most general solution, but can 213 | # result in noticeable delays on some platforms when IPv6 is 214 | # misconfigured or not available, as it calls getaddrinfo with no 215 | # address family specified, so both IPv4 and IPv6 are checked. 216 | # 217 | 218 | # Handle specific option 219 | fqdn = None 220 | address_family = self.config.get('addressfamily', 'any').lower() 221 | if address_family != 'any': 222 | try: 223 | family = socket.AF_INET if address_family == 'inet' \ 224 | else socket.AF_INET6 225 | results = socket.getaddrinfo( 226 | self.host, 227 | None, 228 | family, 229 | socket.SOCK_DGRAM, 230 | socket.IPPROTO_IP, 231 | socket.AI_CANONNAME 232 | ) 233 | for res in results: 234 | af, socktype, proto, canonname, sa = res 235 | if canonname and '.' in canonname: 236 | fqdn = canonname 237 | break 238 | # giaerror -> socket.getaddrinfo() can't resolve self.host 239 | # (which is from socket.gethostname()). Fall back to the 240 | # getfqdn() call below. 241 | except socket.gaierror: 242 | pass 243 | # Handle 'any' / unspecified 244 | if fqdn is None: 245 | fqdn = socket.getfqdn() 246 | # Cache 247 | self.fqdn = fqdn 248 | return self.fqdn 249 | 250 | 251 | class SSHConfig (object): 252 | """ 253 | Representation of config information as stored in the format used by 254 | OpenSSH. Queries can be made via L{lookup}. The format is described in 255 | OpenSSH's C{ssh_config} man page. This class is provided primarily as a 256 | convenience to posix users (since the OpenSSH format is a de-facto 257 | standard on posix) but should work fine on Windows too. 258 | 259 | @since: 1.6 260 | """ 261 | 262 | def __init__(self): 263 | """ 264 | Create a new OpenSSH config object. 265 | """ 266 | self._config = [] 267 | 268 | def parse(self, file_obj): 269 | """ 270 | Read an OpenSSH config from the given file object. 271 | 272 | @param file_obj: a file-like object to read the config file from 273 | @type file_obj: file 274 | """ 275 | host = {"host": ['*'], "config": {}} 276 | for line in file_obj: 277 | line = line.rstrip('\n').lstrip() 278 | if (line == '') or (line[0] == '#'): 279 | continue 280 | if '=' in line: 281 | # Ensure ProxyCommand gets properly split 282 | if line.lower().strip().startswith('proxycommand'): 283 | match = proxy_re.match(line) 284 | key, value = match.group(1).lower(), match.group(2) 285 | else: 286 | key, value = line.split('=', 1) 287 | key = key.strip().lower() 288 | else: 289 | # find first whitespace, and split there 290 | i = 0 291 | while (i < len(line)) and not line[i].isspace(): 292 | i += 1 293 | if i == len(line): 294 | raise Exception('Unparsable line: %r' % line) 295 | key = line[:i].lower() 296 | value = line[i:].lstrip() 297 | 298 | if key == 'host': 299 | self._config.append(host) 300 | value = value.split() 301 | host = {key: value, 'config': {}} 302 | #identityfile, localforward, remoteforward keys are special cases, since they are allowed to be 303 | # specified multiple times and they should be tried in order 304 | # of specification. 305 | 306 | elif key in ['identityfile', 'localforward', 'remoteforward']: 307 | if key in host['config']: 308 | host['config'][key].append(value) 309 | else: 310 | host['config'][key] = [value] 311 | elif key not in host['config']: 312 | host['config'].update({key: value}) 313 | self._config.append(host) 314 | 315 | def lookup(self, hostname): 316 | """ 317 | Return a dict of config options for a given hostname. 318 | 319 | The host-matching rules of OpenSSH's C{ssh_config} man page are used, 320 | which means that all configuration options from matching host 321 | specifications are merged, with more specific hostmasks taking 322 | precedence. In other words, if C{"Port"} is set under C{"Host *"} 323 | and also C{"Host *.example.com"}, and the lookup is for 324 | C{"ssh.example.com"}, then the port entry for C{"Host *.example.com"} 325 | will win out. 326 | 327 | The keys in the returned dict are all normalized to lowercase (look for 328 | C{"port"}, not C{"Port"}. The values are processed according to the 329 | rules for substitution variable expansion in C{ssh_config}. 330 | 331 | @param hostname: the hostname to lookup 332 | @type hostname: str 333 | """ 334 | 335 | matches = [config for config in self._config if 336 | self._allowed(hostname, config['host'])] 337 | 338 | ret = {} 339 | for match in matches: 340 | for key, value in match['config'].iteritems(): 341 | if key not in ret: 342 | # Create a copy of the original value, 343 | # else it will reference the original list 344 | # in self._config and update that value too 345 | # when the extend() is being called. 346 | ret[key] = value[:] 347 | elif key == 'identityfile': 348 | ret[key].extend(value) 349 | ret = self._expand_variables(ret, hostname) 350 | return ret 351 | 352 | def _allowed(self, hostname, hosts): 353 | match = False 354 | for host in hosts: 355 | if host.startswith('!') and fnmatch.fnmatch(hostname, host[1:]): 356 | return False 357 | elif fnmatch.fnmatch(hostname, host): 358 | match = True 359 | return match 360 | 361 | def _expand_variables(self, config, hostname): 362 | """ 363 | Return a dict of config options with expanded substitutions 364 | for a given hostname. 365 | 366 | Please refer to man C{ssh_config} for the parameters that 367 | are replaced. 368 | 369 | @param config: the config for the hostname 370 | @type hostname: dict 371 | @param hostname: the hostname that the config belongs to 372 | @type hostname: str 373 | """ 374 | 375 | if 'hostname' in config: 376 | config['hostname'] = config['hostname'].replace('%h', hostname) 377 | else: 378 | config['hostname'] = hostname 379 | 380 | if 'port' in config: 381 | port = config['port'] 382 | else: 383 | port = SSH_PORT 384 | 385 | user = os.getenv('USER') 386 | if 'user' in config: 387 | remoteuser = config['user'] 388 | else: 389 | remoteuser = user 390 | 391 | host = socket.gethostname().split('.')[0] 392 | fqdn = LazyFqdn(config, host) 393 | homedir = os.path.expanduser('~') 394 | replacements = {'controlpath': 395 | [ 396 | ('%h', config['hostname']), 397 | ('%l', fqdn), 398 | ('%L', host), 399 | ('%n', hostname), 400 | ('%p', port), 401 | ('%r', remoteuser), 402 | ('%u', user) 403 | ], 404 | 'identityfile': 405 | [ 406 | ('~', homedir), 407 | ('%d', homedir), 408 | ('%h', config['hostname']), 409 | ('%l', fqdn), 410 | ('%u', user), 411 | ('%r', remoteuser) 412 | ], 413 | 'proxycommand': 414 | [ 415 | ('%h', config['hostname']), 416 | ('%p', port), 417 | ('%r', remoteuser) 418 | ] 419 | } 420 | 421 | for k in config: 422 | if k in replacements: 423 | for find, replace in replacements[k]: 424 | if isinstance(config[k], list): 425 | for item in range(len(config[k])): 426 | config[k][item] = config[k][item].\ 427 | replace(find, str(replace)) 428 | else: 429 | config[k] = config[k].replace(find, str(replace)) 430 | return config 431 | 432 | #################### 433 | # End copy Paramiko 434 | #################### 435 | 436 | 437 | # The following code block is from Storm ssh 438 | # Licensed under the MIT license by Emre Yilmaz 439 | 440 | from os import makedirs 441 | from os import chmod 442 | from os.path import dirname 443 | from os.path import expanduser 444 | from os.path import exists 445 | from operator import itemgetter 446 | 447 | 448 | class StormConfig(SSHConfig): 449 | def parse(self, file_obj): 450 | """ 451 | Read an OpenSSH config from the given file object. 452 | 453 | @param file_obj: a file-like object to read the config file from 454 | @type file_obj: file 455 | """ 456 | order = 1 457 | host = {"host": ['*'], "config": {}, } 458 | for line in file_obj: 459 | line = line.rstrip('\n').lstrip() 460 | if line == '': 461 | self._config.append({ 462 | 'type': 'empty_line', 463 | 'value': line, 464 | 'host': '', 465 | 'order': order, 466 | }) 467 | order += 1 468 | continue 469 | 470 | if line.startswith('#'): 471 | self._config.append({ 472 | 'type': 'comment', 473 | 'value': line, 474 | 'host': '', 475 | 'order': order, 476 | }) 477 | order += 1 478 | continue 479 | 480 | if '=' in line: 481 | # Ensure ProxyCommand gets properly split 482 | if line.lower().strip().startswith('proxycommand'): 483 | proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I) 484 | match = proxy_re.match(line) 485 | key, value = match.group(1).lower(), match.group(2) 486 | else: 487 | key, value = line.split('=', 1) 488 | key = key.strip().lower() 489 | else: 490 | # find first whitespace, and split there 491 | i = 0 492 | while (i < len(line)) and not line[i].isspace(): 493 | i += 1 494 | if i == len(line): 495 | raise Exception('Unparsable line: %r' % line) 496 | key = line[:i].lower() 497 | value = line[i:].lstrip() 498 | if key == 'host': 499 | self._config.append(host) 500 | value = value.split() 501 | host = {key: value, 'config': {}, 'type': 'entry', 'order': order} 502 | order += 1 503 | #identityfile is a special case, since it is allowed to be 504 | # specified multiple times and they should be tried in order 505 | # of specification. 506 | elif key in ['identityfile', 'localforward', 'remoteforward']: 507 | if key in host['config']: 508 | host['config'][key].append(value) 509 | else: 510 | host['config'][key] = [value] 511 | elif key not in host['config']: 512 | host['config'].update({key: value}) 513 | self._config.append(host) 514 | 515 | 516 | class ConfigParser(object): 517 | """ 518 | Config parser for ~/.ssh/config files. 519 | """ 520 | 521 | def __init__(self, ssh_config_file=None): 522 | if not ssh_config_file: 523 | ssh_config_file = self.get_default_ssh_config_file() 524 | 525 | self.ssh_config_file = ssh_config_file 526 | 527 | if not exists(self.ssh_config_file): 528 | if not exists(dirname(self.ssh_config_file)): 529 | makedirs(dirname(self.ssh_config_file)) 530 | open(self.ssh_config_file, 'w+').close() 531 | chmod(self.ssh_config_file, 0o600) 532 | 533 | self.config_data = [] 534 | 535 | def get_default_ssh_config_file(self): 536 | return expanduser("~/.ssh/config") 537 | 538 | def load(self): 539 | config = StormConfig() 540 | 541 | config.parse(open(self.ssh_config_file)) 542 | for entry in config.__dict__.get("_config"): 543 | if entry.get("type") in ["comment", "empty_line"]: 544 | self.config_data.append(entry) 545 | continue 546 | 547 | host_item = { 548 | 'host': entry["host"][0], 549 | 'options': entry.get("config"), 550 | 'type': 'entry', 551 | 'order': entry.get("order"), 552 | } 553 | 554 | if len(entry["host"]) > 1: 555 | host_item.update({ 556 | 'host': " ".join(entry["host"]), 557 | }) 558 | 559 | # minor bug in paramiko.SSHConfig that duplicates 560 | #"Host *" entries. 561 | if entry.get("config") and len(entry.get("config")) > 0: 562 | self.config_data.append(host_item) 563 | return self.config_data 564 | 565 | def add_host(self, host, options): 566 | self.config_data.append({ 567 | 'host': host, 568 | 'options': options, 569 | 'order': self.get_last_index(), 570 | }) 571 | 572 | return self 573 | 574 | def update_host(self, host, options): 575 | for index, host_entry in enumerate(self.config_data): 576 | if host_entry.get("host") == host: 577 | self.config_data[index]["options"] = options 578 | 579 | return self 580 | 581 | def search_host(self, search_string): 582 | results = [] 583 | for host_entry in self.config_data: 584 | if host_entry.get("type") != 'entry': 585 | continue 586 | 587 | searchable_information = host_entry.get("host") 588 | for key, value in host_entry.get("options").items(): 589 | if isinstance(value, list): 590 | value = " ".join(value) 591 | if isinstance(value, int): 592 | value = str(value) 593 | 594 | searchable_information += " " + value 595 | 596 | if search_string in searchable_information: 597 | results.append(host_entry) 598 | 599 | return results 600 | 601 | def delete_host(self, host): 602 | found = 0 603 | for index, host_entry in enumerate(self.config_data): 604 | if host_entry.get("host") == host: 605 | del self.config_data[index] 606 | found += 1 607 | 608 | if found == 0: 609 | raise StormValueError('No host found') 610 | return self 611 | 612 | def delete_all_hosts(self): 613 | self.config_data = [] 614 | self.write_to_ssh_config() 615 | 616 | return self 617 | 618 | def dump(self): 619 | if len(self.config_data) < 1: 620 | return 621 | 622 | file_content = "" 623 | self.config_data = sorted(self.config_data, key=itemgetter("order")) 624 | 625 | replacements = {} 626 | for keyword in SSH_KEYWORDS: 627 | replacements[keyword.lower()] = keyword 628 | 629 | for host_item in self.config_data: 630 | if host_item.get("type") in ['comment', 'empty_line']: 631 | file_content += host_item.get("value") + "\n" 632 | continue 633 | host_item_content = "Host {0}\n".format(host_item.get("host")) 634 | for key, value in host_item.get("options").iteritems(): 635 | if key in replacements: 636 | key = replacements[key] 637 | if isinstance(value, list): 638 | sub_content = "" 639 | for value_ in value: 640 | sub_content += " {0} {1}\n".format( 641 | key, value_ 642 | ) 643 | host_item_content += sub_content 644 | else: 645 | host_item_content += " {0} {1}\n".format( 646 | key, value 647 | ) 648 | file_content += host_item_content 649 | 650 | return file_content 651 | 652 | def write_to_ssh_config(self): 653 | with open(self.ssh_config_file, 'w+') as f: 654 | data = self.dump() 655 | if data: 656 | f.write(data) 657 | return self 658 | 659 | def get_last_index(self): 660 | last_index = 0 661 | indexes = [] 662 | for item in self.config_data: 663 | if item.get("order"): 664 | indexes.append(item.get("order")) 665 | if len(indexes) > 0: 666 | last_index = max(indexes) 667 | 668 | return last_index 669 | 670 | 671 | ################# 672 | # End copy Storm 673 | ################# 674 | 675 | # The following code is by Björn Andersson for Ansible 676 | 677 | 678 | def change_host(options, **kwargs): 679 | options = copy.deepcopy(options) 680 | changed = False 681 | for k, v in kwargs.items(): 682 | if '_' in k: 683 | k = k.replace('_', '') 684 | 685 | if not v: 686 | if options.get(k): 687 | del options[k] 688 | changed = True 689 | elif ( options.get(k) != v ) and not ( type(options.get(k)) is list and v in options.get(k) ): 690 | options[k] = v 691 | changed = True 692 | 693 | return changed, options 694 | 695 | 696 | def main(): 697 | module = AnsibleModule( 698 | argument_spec=dict( 699 | state=dict(default='present', choices=['present', 'absent']), 700 | host=dict(required=True, type='str'), 701 | hostname=dict(type='str'), 702 | port=dict(type='str'), 703 | remote_user=dict(type='str'), 704 | identity_file=dict(type='str'), 705 | user=dict(default=None, type='str'), 706 | user_known_hosts_file=dict(default=None, type='str'), 707 | proxycommand=dict(default=None, type='str'), 708 | strict_host_key_checking=dict( 709 | default=None, 710 | choices=['yes', 'no', 'ask'] 711 | ), 712 | ), 713 | supports_check_mode=True 714 | ) 715 | 716 | user = module.params.get('user') 717 | host = module.params.get('host') 718 | args = dict( 719 | hostname=module.params.get('hostname'), 720 | port=module.params.get('port'), 721 | identity_file=module.params.get('identity_file'), 722 | user=module.params.get('remote_user'), 723 | strict_host_key_checking=module.params.get('strict_host_key_checking'), 724 | user_known_hosts_file=module.params.get('user_known_hosts_file'), 725 | proxycommand=module.params.get('proxycommand'), 726 | ) 727 | state = module.params.get('state') 728 | config_changed = False 729 | hosts_changed = [] 730 | hosts_removed = [] 731 | hosts_added = [] 732 | 733 | if user is None: 734 | config_file = '/etc/ssh/ssh_config' 735 | user = 'root' 736 | else: 737 | config_file = os.path.join( 738 | os.path.expanduser('~{0}'.format(user)), '.ssh', 'config' 739 | ) 740 | 741 | # See if the identity file exists or not, relative to the config file 742 | if os.path.exists(config_file) and args['identity_file']: 743 | dirname = os.path.dirname(config_file) 744 | identity_file = args['identity_file'] 745 | if(not identity_file.startswith('/') and 746 | not identity_file.startswith('~')): 747 | identity_file = os.path.join(dirname, identity_file) 748 | 749 | if(not os.path.exists(identity_file) and 750 | not os.path.exists(os.path.expanduser(identity_file))): 751 | module.fail_json( 752 | msg='IdentityFile "{0}" does not exist'.format(identity_file) 753 | ) 754 | 755 | config = ConfigParser(config_file) 756 | config.load() 757 | 758 | results = config.search_host(host) 759 | if results: 760 | for h in results: 761 | # Anything to remove? 762 | if state == 'absent': 763 | config_changed = True 764 | hosts_removed.append(h['host']) 765 | config.delete_host(h['host']) 766 | # Anything to change? 767 | else: 768 | changed, options = change_host(h['options'], **args) 769 | 770 | if changed: 771 | config_changed = True 772 | config.update_host(h['host'], options) 773 | hosts_changed.append({ 774 | h['host']: { 775 | 'old': h['options'], 776 | 'new': options, 777 | } 778 | }) 779 | # Anything to add? 780 | elif state == 'present': 781 | changed, options = change_host(dict(), **args) 782 | 783 | if changed: 784 | config_changed = True 785 | hosts_added.append(host) 786 | config.add_host(host, options) 787 | 788 | if config_changed and not module.check_mode: 789 | config.write_to_ssh_config() 790 | # MAKE sure the file is owned by the right user 791 | module.set_owner_if_different(config_file, user, False) 792 | module.set_group_if_different(config_file, user, False) 793 | module.set_mode_if_different(config_file, '0600', False) 794 | 795 | module.exit_json(changed=config_changed, 796 | hosts_changed=hosts_changed, 797 | hosts_removed=hosts_removed, 798 | hosts_added=hosts_added) 799 | 800 | # this is magic, see lib/ansible/module_common.py 801 | #<> 802 | 803 | main() 804 | --------------------------------------------------------------------------------