diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 57f1732a02..ed5fd8eb15 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1070,6 +1070,10 @@ files: labels: interfaces_file $modules/system/iptables_state.py: maintainers: quidame + $modules/system/keyring.py: + maintainers: ahussey-redhat + $modules/system/keyring_info.py: + maintainers: ahussey-redhat $modules/system/shutdown.py: maintainers: nitzmahone samdoran aminvakil $modules/system/java_cert.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index 14391a7e71..4c6c7823bb 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -608,6 +608,10 @@ plugin_routing: redirect: community.general.identity.keycloak.keycloak_role keycloak_user_federation: redirect: community.general.identity.keycloak.keycloak_user_federation + keyring: + redirect: community.general.system.keyring + keyring_info: + redirect: community.general.system.keyring_info kibana_plugin: redirect: community.general.database.misc.kibana_plugin kubevirt_cdi_upload: diff --git a/plugins/modules/system/keyring.py b/plugins/modules/system/keyring.py new file mode 100644 index 0000000000..7e89484a6c --- /dev/null +++ b/plugins/modules/system/keyring.py @@ -0,0 +1,270 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Alexander Hussey +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Ansible Module - community.general.keyring +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: keyring +version_added: 5.2.0 +author: + - Alexander Hussey (@ahussey-redhat) +short_description: Set or delete a passphrase using the Operating System's native keyring +description: >- + This module uses the L(keyring Python library, https://pypi.org/project/keyring/) + to set or delete passphrases for a given service and username from the OS' native keyring. +requirements: + - keyring (Python library) + - gnome-keyring (application - required for headless Gnome keyring access) + - dbus-run-session (application - required for headless Gnome keyring access) +options: + service: + description: The name of the service. + required: true + type: str + username: + description: The user belonging to the service. + required: true + type: str + user_password: + description: The password to set. + required: false + type: str + aliases: + - password + keyring_password: + description: Password to unlock keyring. + required: true + type: str + state: + description: Whether the password should exist. + required: false + default: present + type: str + choices: + - present + - absent +""" + +EXAMPLES = r""" +- name: Set a password for test/test1 + community.general.keyring: + service: test + username: test1 + user_password: "{{ user_password }}" + keyring_password: "{{ keyring_password }}" + +- name: Delete the password for test/test1 + community.general.keyring: + service: test + username: test1 + user_password: "{{ user_password }}" + keyring_password: "{{ keyring_password }}" + state: absent +""" + +try: + from shlex import quote +except ImportError: + from pipes import quote +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +try: + import keyring + + HAS_KEYRING = True +except ImportError: + HAS_KEYRING = False + KEYRING_IMP_ERR = traceback.format_exc() + + +def del_passphrase(module): + """ + Attempt to delete a passphrase in the keyring using the Python API and fallback to using a shell. + """ + if module.check_mode: + return None + try: + keyring.delete_password(module.params["service"], module.params["username"]) + return None + except keyring.errors.KeyringLocked as keyring_locked_err: # pylint: disable=unused-variable + delete_argument = ( + 'echo "%s" | gnome-keyring-daemon --unlock\nkeyring del %s %s\n' + % ( + quote(module.params["keyring_password"]), + quote(module.params["service"]), + quote(module.params["username"]), + ) + ) + dummy, dummy, stderr = module.run_command( + "dbus-run-session -- /bin/bash", + use_unsafe_shell=True, + data=delete_argument, + encoding=None, + ) + + if not stderr.decode("UTF-8"): + return None + return stderr.decode("UTF-8") + + +def set_passphrase(module): + """ + Attempt to set passphrase in the keyring using the Python API and fallback to using a shell. + """ + if module.check_mode: + return None + try: + keyring.set_password( + module.params["service"], + module.params["username"], + module.params["user_password"], + ) + return None + except keyring.errors.KeyringLocked as keyring_locked_err: # pylint: disable=unused-variable + set_argument = ( + 'echo "%s" | gnome-keyring-daemon --unlock\nkeyring set %s %s\n%s\n' + % ( + quote(module.params["keyring_password"]), + quote(module.params["service"]), + quote(module.params["username"]), + quote(module.params["user_password"]), + ) + ) + dummy, dummy, stderr = module.run_command( + "dbus-run-session -- /bin/bash", + use_unsafe_shell=True, + data=set_argument, + encoding=None, + ) + if not stderr.decode("UTF-8"): + return None + return stderr.decode("UTF-8") + + +def get_passphrase(module): + """ + Attempt to retrieve passphrase from keyring using the Python API and fallback to using a shell. + """ + try: + passphrase = keyring.get_password( + module.params["service"], module.params["username"] + ) + return passphrase + except keyring.errors.KeyringLocked: + pass + except keyring.errors.InitError: + pass + except AttributeError: + pass + get_argument = 'echo "%s" | gnome-keyring-daemon --unlock\nkeyring get %s %s\n' % ( + quote(module.params["keyring_password"]), + quote(module.params["service"]), + quote(module.params["username"]), + ) + dummy, stdout, dummy = module.run_command( + "dbus-run-session -- /bin/bash", + use_unsafe_shell=True, + data=get_argument, + encoding=None, + ) + try: + return stdout.decode("UTF-8").splitlines()[1] # Only return the line containing the password + except IndexError: + return None + + +def run_module(): + """ + Attempts to retrieve a passphrase from a keyring. + """ + result = dict( + changed=False, + msg="", + ) + + module_args = dict( + service=dict(type="str", required=True), + username=dict(type="str", required=True), + keyring_password=dict(type="str", required=True, no_log=True), + user_password=dict( + type="str", required=False, no_log=True, aliases=["password"] + ), + state=dict( + type="str", required=False, default="present", choices=["absent", "present"] + ), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + if not HAS_KEYRING: + module.fail_json(msg=missing_required_lib("keyring"), exception=KEYRING_IMP_ERR) + + passphrase = get_passphrase(module) + if module.params["state"] == "present": + if passphrase is not None: + if passphrase == module.params["user_password"]: + result["msg"] = "Passphrase already set for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + if passphrase != module.params["user_password"]: + set_result = set_passphrase(module) + if set_result is None: + result["changed"] = True + result["msg"] = "Passphrase has been updated for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + if set_result is not None: + module.fail_json(msg=set_result) + if passphrase is None: + set_result = set_passphrase(module) + if set_result is None: + result["changed"] = True + result["msg"] = "Passphrase has been updated for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + if set_result is not None: + module.fail_json(msg=set_result) + + if module.params["state"] == "absent": + if not passphrase: + result["result"] = "Passphrase already absent for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + if passphrase: + del_result = del_passphrase(module) + if del_result is None: + result["changed"] = True + result["msg"] = "Passphrase has been removed for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + if del_result is not None: + module.fail_json(msg=del_result) + + module.exit_json(**result) + + +def main(): + """ + main module loop + """ + run_module() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/system/keyring_info.py b/plugins/modules/system/keyring_info.py new file mode 100644 index 0000000000..4d3e5ee995 --- /dev/null +++ b/plugins/modules/system/keyring_info.py @@ -0,0 +1,149 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Alexander Hussey +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Ansible Module - community.general.keyring_info +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: keyring_info +version_added: 5.2.0 +author: + - Alexander Hussey (@ahussey-redhat) +short_description: Get a passphrase using the Operating System's native keyring +description: >- + This module uses the L(keyring Python library, https://pypi.org/project/keyring/) + to retrieve passphrases for a given service and username from the OS' native keyring. +requirements: + - keyring (Python library) + - gnome-keyring (application - required for headless Linux keyring access) + - dbus-run-session (application - required for headless Linux keyring access) +options: + service: + description: The name of the service. + required: true + type: str + username: + description: The user belonging to the service. + required: true + type: str + keyring_password: + description: Password to unlock keyring. + required: true + type: str +""" + +EXAMPLES = r""" + - name: Retrieve password for service_name/user_name + community.general.keyring_info: + service: test + username: test1 + keyring_password: "{{ keyring_password }}" + register: test_password + + - name: Display password + ansible.builtin.debug: + msg: "{{ test_password.passphrase }}" +""" + +RETURN = r""" + passphrase: + description: A string containing the password. + returned: success and the password exists + type: str + sample: Password123 +""" + +try: + from shlex import quote +except ImportError: + from pipes import quote +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +try: + import keyring + + HAS_KEYRING = True +except ImportError: + HAS_KEYRING = False + KEYRING_IMP_ERR = traceback.format_exc() + + +def _alternate_retrieval_method(module): + get_argument = 'echo "%s" | gnome-keyring-daemon --unlock\nkeyring get %s %s\n' % ( + quote(module.params["keyring_password"]), + quote(module.params["service"]), + quote(module.params["username"]), + ) + dummy, stdout, dummy = module.run_command( + "dbus-run-session -- /bin/bash", + use_unsafe_shell=True, + data=get_argument, + encoding=None, + ) + try: + return stdout.decode("UTF-8").splitlines()[1] + except IndexError: + return None + + +def run_module(): + """ + Attempts to retrieve a passphrase from a keyring. + """ + result = dict(changed=False, msg="") + + module_args = dict( + service=dict(type="str", required=True), + username=dict(type="str", required=True), + keyring_password=dict(type="str", required=True, no_log=True), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + if not HAS_KEYRING: + module.fail_json(msg=missing_required_lib("keyring"), exception=KEYRING_IMP_ERR) + try: + passphrase = keyring.get_password( + module.params["service"], module.params["username"] + ) + except keyring.errors.KeyringLocked: + pass + except keyring.errors.InitError: + pass + except AttributeError: + pass + passphrase = _alternate_retrieval_method(module) + + if passphrase is not None: + result["msg"] = "Successfully retrieved password for %s@%s" % ( + module.params["service"], + module.params["username"], + ) + result["passphrase"] = passphrase + if passphrase is None: + result["msg"] = "Password for %s@%s does not exist." % ( + module.params["service"], + module.params["username"], + ) + module.exit_json(**result) + + +def main(): + """ + main module loop + """ + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/keyring/aliases b/tests/integration/targets/keyring/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/keyring/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/keyring/tasks/main.yml b/tests/integration/targets/keyring/tasks/main.yml new file mode 100644 index 0000000000..f4911b25a9 --- /dev/null +++ b/tests/integration/targets/keyring/tasks/main.yml @@ -0,0 +1,95 @@ +--- +- name: Ensure required packages for headless keyring access are installed (RPM) + ansible.builtin.package: + name: gnome-keyring + become: true + when: "'localhost' not in inventory_hostname" + +- name: Ensure keyring is installed (RPM) + ansible.builtin.dnf: + name: python3-keyring + state: present + become: true + when: ansible_facts['os_family'] == 'RedHat' + +- name: Ensure keyring is installed (pip) + ansible.builtin.pip: + name: keyring + state: present + become: true + when: ansible_facts['os_family'] != 'RedHat' + +# Set password for new account +# Expected result: success +- name: Set password for test/test1 + community.general.keyring: + service: test + username: test1 + user_password: "{{ user_password }}" + keyring_password: "{{ keyring_password }}" + register: set_password + +- name: Assert that the password has been set + ansible.builtin.assert: + that: + - set_password.msg == "Passphrase has been updated for test@test1" + +# Print out password to confirm it has been set +# Expected result: success +- name: Retrieve password for test/test1 + community.general.keyring_info: + service: test + username: test1 + keyring_password: "{{ keyring_password }}" + register: test_set_password + +- name: Assert that the password exists + ansible.builtin.assert: + that: + - test_set_password.passphrase == user_password + +# Attempt to set password again +# Expected result: success - nothing should happen +- name: Attempt to re-set password for test/test1 + community.general.keyring: + service: test + username: test1 + user_password: "{{ user_password }}" + keyring_password: "{{ keyring_password }}" + register: second_set_password + +- name: Assert that the password has not been changed + ansible.builtin.assert: + that: + - second_set_password.msg == "Passphrase already set for test@test1" + +# Delete account +# Expected result: success +- name: Delete password for test/test1 + community.general.keyring: + service: test + username: test1 + user_password: "{{ user_password }}" + keyring_password: "{{ keyring_password }}" + state: absent + register: del_password + +- name: Assert that the password has been deleted + ansible.builtin.assert: + that: + - del_password.msg == "Passphrase has been removed for test@test1" + +# Attempt to get deleted account (to confirm it has been deleted). +# Don't use `no_log` as run completes due to failed task. +# Expected result: fail +- name: Retrieve password for test/test1 + community.general.keyring_info: + service: test + username: test1 + keyring_password: "{{ keyring_password }}" + register: test_del_password + +- name: Assert that the password no longer exists + ansible.builtin.assert: + that: + - test_del_password.passphrase is not defined \ No newline at end of file diff --git a/tests/integration/targets/keyring/vars/main.yml b/tests/integration/targets/keyring/vars/main.yml new file mode 100644 index 0000000000..f1eb1f80e5 --- /dev/null +++ b/tests/integration/targets/keyring/vars/main.yml @@ -0,0 +1,3 @@ +--- +keyring_password: Password123 +user_password: Test123