1#!/usr/bin/python 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2012, Michael DeHaan <michael.dehaan@gmail.com> 5# Copyright: (c) 2012, Jayson Vantuyl <jayson@aggressive.ly> 6 7# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 8 9from __future__ import absolute_import, division, print_function 10__metaclass__ = type 11 12 13DOCUMENTATION = ''' 14--- 15module: apt_key 16author: 17- Jayson Vantuyl (@jvantuyl) 18version_added: "1.0" 19short_description: Add or remove an apt key 20description: 21 - Add or remove an I(apt) key, optionally downloading it. 22notes: 23 - Doesn't download the key unless it really needs it. 24 - As a sanity check, downloaded key id must match the one specified. 25 - "Use full fingerprint (40 characters) key ids to avoid key collisions. 26 To generate a full-fingerprint imported key: C(apt-key adv --list-public-keys --with-fingerprint --with-colons)." 27 - If you specify both the key id and the URL with C(state=present), the task can verify or add the key as needed. 28 - Adding a new key requires an apt cache update (e.g. using the M(ansible.builtin.apt) module's update_cache option). 29 - Supports C(check_mode). 30requirements: 31 - gpg 32options: 33 id: 34 description: 35 - The identifier of the key. 36 - Including this allows check mode to correctly report the changed state. 37 - If specifying a subkey's id be aware that apt-key does not understand how to remove keys via a subkey id. Specify the primary key's id instead. 38 - This parameter is required when C(state) is set to C(absent). 39 data: 40 description: 41 - The keyfile contents to add to the keyring. 42 file: 43 description: 44 - The path to a keyfile on the remote server to add to the keyring. 45 keyring: 46 description: 47 - The full path to specific keyring file in C(/etc/apt/trusted.gpg.d/). 48 version_added: "1.3" 49 url: 50 description: 51 - The URL to retrieve key from. 52 keyserver: 53 description: 54 - The keyserver to retrieve key from. 55 version_added: "1.6" 56 state: 57 description: 58 - Ensures that the key is present (added) or absent (revoked). 59 choices: [ absent, present ] 60 default: present 61 validate_certs: 62 description: 63 - If C(no), SSL certificates for the target url will not be validated. This should only be used 64 on personally controlled sites using self-signed certificates. 65 type: bool 66 default: 'yes' 67''' 68 69EXAMPLES = ''' 70- name: Add an apt key by id from a keyserver 71 ansible.builtin.apt_key: 72 keyserver: keyserver.ubuntu.com 73 id: 36A1D7869245C8950F966E92D8576A8BA88D21E9 74 75- name: Add an Apt signing key, uses whichever key is at the URL 76 ansible.builtin.apt_key: 77 url: https://ftp-master.debian.org/keys/archive-key-6.0.asc 78 state: present 79 80- name: Add an Apt signing key, will not download if present 81 ansible.builtin.apt_key: 82 id: 9FED2BCBDCD29CDF762678CBAED4B06F473041FA 83 url: https://ftp-master.debian.org/keys/archive-key-6.0.asc 84 state: present 85 86- name: Remove a Apt specific signing key, leading 0x is valid 87 ansible.builtin.apt_key: 88 id: 0x9FED2BCBDCD29CDF762678CBAED4B06F473041FA 89 state: absent 90 91# Use armored file since utf-8 string is expected. Must be of "PGP PUBLIC KEY BLOCK" type. 92- name: Add a key from a file on the Ansible server 93 ansible.builtin.apt_key: 94 data: "{{ lookup('file', 'apt.asc') }}" 95 state: present 96 97- name: Add an Apt signing key to a specific keyring file 98 ansible.builtin.apt_key: 99 id: 9FED2BCBDCD29CDF762678CBAED4B06F473041FA 100 url: https://ftp-master.debian.org/keys/archive-key-6.0.asc 101 keyring: /etc/apt/trusted.gpg.d/debian.gpg 102 103- name: Add Apt signing key on remote server to keyring 104 ansible.builtin.apt_key: 105 id: 9FED2BCBDCD29CDF762678CBAED4B06F473041FA 106 file: /tmp/apt.gpg 107 state: present 108''' 109 110RETURN = '''#''' 111 112# FIXME: standardize into module_common 113from traceback import format_exc 114 115from ansible.module_utils.basic import AnsibleModule 116from ansible.module_utils._text import to_native 117from ansible.module_utils.urls import fetch_url 118 119 120apt_key_bin = None 121 122 123def find_needed_binaries(module): 124 global apt_key_bin 125 126 apt_key_bin = module.get_bin_path('apt-key', required=True) 127 128 # FIXME: Is there a reason that gpg and grep are checked? Is it just 129 # cruft or does the apt .deb package not require them (and if they're not 130 # installed, /usr/bin/apt-key fails?) 131 module.get_bin_path('gpg', required=True) 132 module.get_bin_path('grep', required=True) 133 134 135def parse_key_id(key_id): 136 """validate the key_id and break it into segments 137 138 :arg key_id: The key_id as supplied by the user. A valid key_id will be 139 8, 16, or more hexadecimal chars with an optional leading ``0x``. 140 :returns: The portion of key_id suitable for apt-key del, the portion 141 suitable for comparisons with --list-public-keys, and the portion that 142 can be used with --recv-key. If key_id is long enough, these will be 143 the last 8 characters of key_id, the last 16 characters, and all of 144 key_id. If key_id is not long enough, some of the values will be the 145 same. 146 147 * apt-key del <= 1.10 has a bug with key_id != 8 chars 148 * apt-key adv --list-public-keys prints 16 chars 149 * apt-key adv --recv-key can take more chars 150 151 """ 152 # Make sure the key_id is valid hexadecimal 153 int(key_id, 16) 154 155 key_id = key_id.upper() 156 if key_id.startswith('0X'): 157 key_id = key_id[2:] 158 159 key_id_len = len(key_id) 160 if (key_id_len != 8 and key_id_len != 16) and key_id_len <= 16: 161 raise ValueError('key_id must be 8, 16, or 16+ hexadecimal characters in length') 162 163 short_key_id = key_id[-8:] 164 165 fingerprint = key_id 166 if key_id_len > 16: 167 fingerprint = key_id[-16:] 168 169 return short_key_id, fingerprint, key_id 170 171 172def all_keys(module, keyring, short_format): 173 if keyring: 174 cmd = "%s --keyring %s adv --list-public-keys --keyid-format=long" % (apt_key_bin, keyring) 175 else: 176 cmd = "%s adv --list-public-keys --keyid-format=long" % apt_key_bin 177 (rc, out, err) = module.run_command(cmd) 178 results = [] 179 lines = to_native(out).split('\n') 180 for line in lines: 181 if (line.startswith("pub") or line.startswith("sub")) and "expired" not in line: 182 tokens = line.split() 183 code = tokens[1] 184 (len_type, real_code) = code.split("/") 185 results.append(real_code) 186 if short_format: 187 results = shorten_key_ids(results) 188 return results 189 190 191def shorten_key_ids(key_id_list): 192 """ 193 Takes a list of key ids, and converts them to the 'short' format, 194 by reducing them to their last 8 characters. 195 """ 196 short = [] 197 for key in key_id_list: 198 short.append(key[-8:]) 199 return short 200 201 202def download_key(module, url): 203 # FIXME: move get_url code to common, allow for in-memory D/L, support proxies 204 # and reuse here 205 if url is None: 206 module.fail_json(msg="needed a URL but was not specified") 207 208 try: 209 rsp, info = fetch_url(module, url) 210 if info['status'] != 200: 211 module.fail_json(msg="Failed to download key at %s: %s" % (url, info['msg'])) 212 213 return rsp.read() 214 except Exception: 215 module.fail_json(msg="error getting key id from url: %s" % url, traceback=format_exc()) 216 217 218def import_key(module, keyring, keyserver, key_id): 219 if keyring: 220 cmd = "%s --keyring %s adv --no-tty --keyserver %s --recv %s" % (apt_key_bin, keyring, keyserver, key_id) 221 else: 222 cmd = "%s adv --no-tty --keyserver %s --recv %s" % (apt_key_bin, keyserver, key_id) 223 for retry in range(5): 224 lang_env = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C') 225 (rc, out, err) = module.run_command(cmd, environ_update=lang_env) 226 if rc == 0: 227 break 228 else: 229 # Out of retries 230 if rc == 2 and 'not found on keyserver' in out: 231 msg = 'Key %s not found on keyserver %s' % (key_id, keyserver) 232 module.fail_json(cmd=cmd, msg=msg) 233 else: 234 msg = "Error fetching key %s from keyserver: %s" % (key_id, keyserver) 235 module.fail_json(cmd=cmd, msg=msg, rc=rc, stdout=out, stderr=err) 236 return True 237 238 239def add_key(module, keyfile, keyring, data=None): 240 if data is not None: 241 if keyring: 242 cmd = "%s --keyring %s add -" % (apt_key_bin, keyring) 243 else: 244 cmd = "%s add -" % apt_key_bin 245 (rc, out, err) = module.run_command(cmd, data=data, check_rc=True, binary_data=True) 246 else: 247 if keyring: 248 cmd = "%s --keyring %s add %s" % (apt_key_bin, keyring, keyfile) 249 else: 250 cmd = "%s add %s" % (apt_key_bin, keyfile) 251 (rc, out, err) = module.run_command(cmd, check_rc=True) 252 return True 253 254 255def remove_key(module, key_id, keyring): 256 # FIXME: use module.run_command, fail at point of error and don't discard useful stdin/stdout 257 if keyring: 258 cmd = '%s --keyring %s del %s' % (apt_key_bin, keyring, key_id) 259 else: 260 cmd = '%s del %s' % (apt_key_bin, key_id) 261 (rc, out, err) = module.run_command(cmd, check_rc=True) 262 return True 263 264 265def main(): 266 module = AnsibleModule( 267 argument_spec=dict( 268 id=dict(type='str'), 269 url=dict(type='str'), 270 data=dict(type='str'), 271 file=dict(type='path'), 272 key=dict(type='str'), 273 keyring=dict(type='path'), 274 validate_certs=dict(type='bool', default=True), 275 keyserver=dict(type='str'), 276 state=dict(type='str', default='present', choices=['absent', 'present']), 277 ), 278 supports_check_mode=True, 279 mutually_exclusive=(('data', 'filename', 'keyserver', 'url'),), 280 ) 281 282 key_id = module.params['id'] 283 url = module.params['url'] 284 data = module.params['data'] 285 filename = module.params['file'] 286 keyring = module.params['keyring'] 287 state = module.params['state'] 288 keyserver = module.params['keyserver'] 289 changed = False 290 291 fingerprint = short_key_id = key_id 292 short_format = False 293 if key_id: 294 try: 295 short_key_id, fingerprint, key_id = parse_key_id(key_id) 296 except ValueError: 297 module.fail_json(msg='Invalid key_id', id=key_id) 298 299 if len(fingerprint) == 8: 300 short_format = True 301 302 find_needed_binaries(module) 303 304 keys = all_keys(module, keyring, short_format) 305 return_values = {} 306 307 if state == 'present': 308 if fingerprint and fingerprint in keys: 309 module.exit_json(changed=False) 310 elif fingerprint and fingerprint not in keys and module.check_mode: 311 # TODO: Someday we could go further -- write keys out to 312 # a temporary file and then extract the key id from there via gpg 313 # to decide if the key is installed or not. 314 module.exit_json(changed=True) 315 else: 316 if not filename and not data and not keyserver: 317 data = download_key(module, url) 318 319 if filename: 320 add_key(module, filename, keyring) 321 elif keyserver: 322 import_key(module, keyring, keyserver, key_id) 323 else: 324 add_key(module, "-", keyring, data) 325 326 changed = False 327 keys2 = all_keys(module, keyring, short_format) 328 if len(keys) != len(keys2): 329 changed = True 330 331 if fingerprint and fingerprint not in keys2: 332 module.fail_json(msg="key does not seem to have been added", id=key_id) 333 module.exit_json(changed=changed) 334 335 elif state == 'absent': 336 if not key_id: 337 module.fail_json(msg="key is required") 338 if fingerprint in keys: 339 if module.check_mode: 340 module.exit_json(changed=True) 341 342 # we use the "short" id: key_id[-8:], short_format=True 343 # it's a workaround for https://bugs.launchpad.net/ubuntu/+source/apt/+bug/1481871 344 if remove_key(module, short_key_id, keyring): 345 keys = all_keys(module, keyring, short_format) 346 if fingerprint in keys: 347 module.fail_json(msg="apt-key del did not return an error but the key was not removed (check that the id is correct and *not* a subkey)", 348 id=key_id) 349 changed = True 350 else: 351 # FIXME: module.fail_json or exit-json immediately at point of failure 352 module.fail_json(msg="error removing key_id", **return_values) 353 354 module.exit_json(changed=changed, **return_values) 355 356 357if __name__ == '__main__': 358 main() 359