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