From 954420e9dee6eadaea1c30f9e234f0b3a43e96d9 Mon Sep 17 00:00:00 2001 From: Nis Wechselberg Date: Sun, 11 Feb 2024 16:29:29 +0100 Subject: [PATCH] Fixed Duplicity Roles --- plugins/action/gpg_key.py | 1556 +++++++++++++++++ roles/duplicity_client/defaults/main.yml | 6 +- roles/duplicity_client/handlers/main.yml | 2 - roles/duplicity_client/tasks/main.yml | 44 +- roles/duplicity_client/tests/inventory | 2 - roles/duplicity_client/tests/test.yml | 5 - roles/duplicity_client/vars/main.yml | 2 - roles/duplicity_server/defaults/main.yml | 4 + roles/duplicity_server/handlers/main.yml | 2 - roles/duplicity_server/tasks/main.yml | 206 +-- .../templates/backup-script.j2 | 24 + roles/duplicity_server/tests/inventory | 2 - roles/duplicity_server/tests/test.yml | 5 - roles/duplicity_server/vars/main.yml | 2 - 14 files changed, 1707 insertions(+), 155 deletions(-) create mode 100644 plugins/action/gpg_key.py delete mode 100644 roles/duplicity_client/handlers/main.yml delete mode 100644 roles/duplicity_client/tests/inventory delete mode 100644 roles/duplicity_client/tests/test.yml delete mode 100644 roles/duplicity_client/vars/main.yml delete mode 100644 roles/duplicity_server/handlers/main.yml create mode 100644 roles/duplicity_server/templates/backup-script.j2 delete mode 100644 roles/duplicity_server/tests/inventory delete mode 100644 roles/duplicity_server/tests/test.yml delete mode 100644 roles/duplicity_server/vars/main.yml diff --git a/plugins/action/gpg_key.py b/plugins/action/gpg_key.py new file mode 100644 index 0000000..b805786 --- /dev/null +++ b/plugins/action/gpg_key.py @@ -0,0 +1,1556 @@ +#!/usr/bin/python + +# Copyright: (c) 2019, Rinck H. Sonnenberg - Netson +# License: MIT + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: gpg_key +short_description: Module to install and trust GPG keys +version_added: "2.7" +description: | + Module to install and trust GPG keys from files and keyservers. + I shouldn't have to tell you that it is a BAD idea to store your + secret keys inside a playbook or role! Please take approriate measures + to protect your sensitive information from falling into the wrong hands! +options: + fpr: + description: | + Key Fingerprint to install from keyserver, to delete from target + machine, or to get info on. To get info on all installed keys, + use * as the value for fpr. Using any shorter ID than the full + fingerprint will fail. Using the short ID's isn't recommended + anyways, due to possible collisions. + required: false + type: str + keyserver: + description: Keyserver to download key from + default: keyserver.ubuntu.com + type: str + file: + description: | + File on target machine containing the key(s) to install; + be aware that a file can contain more than 1 key; if this + is the case, all keys will be imported and all keys will + receive the same trust level. The module auto-detects if + the given key is a public or secret key. + required: false + type: path + content: + description: | + Contents of keyfile to install on target machine + just like the file, the contents can contain more than 1 key + and all keys will receive the same trust level. The module + auto-detects if the given key is a public or secret key. + The content parameter simply creates a temporary file on the + target host and then performs the same actions as the file + parameter. It is just an easy method to not have to create + a keyfile on the target machine first. + required: false + type: str + manage_trust: + description: | + Setting controls wether or not the module controls the trust levels + of the (imported) keys. If set to false, no changes will be made to + the trust level regardless of the 'trust' setting. + default: true + type: bool + trust: + description: | + Trust level to apply to newly imported keys or existing keys; + please keep in mind that keys with a trust level other than 5 + need to be signed by a fully trusted key in order to effectively + set the trust level. If your key is not signed by a fully trusted + key and the trust level is 2, 3 or 4, the module will report a + changed state on each run due to the fact that GnuPG will report + an 'Unknown' trust level. + choices: + - 1 + - 2 + - 3 + - 4 + - 5 + default: 1 + type: str + state: + description: | + Key should be present, absent, latest (keyserver only) or info. + Info only shows info for key given via fpr. Alternatively, you + can use the special value * for the fpr to get a list of all + installed keys and their relevant info. + default: present + type: str + choices: + - present + - absent + - latest + - info + gpgbin: + description: Full path to GnuPG binary on target host + default: uses get_bin_path method to find gpg + type: path + homedir: + description: | + Full path to the gpg homedir you wish to use; If none is provided, + gpg will use the default homedir of ~/.gnupg + Please be aware that this will be the user executing the module + on the target host! So there will likely be a difference between + running the module with and without become:yes! If you don't want to + be surprised, set the path to the homedir with the variable. For more + information on the GnuPG homedir, check + https://www.gnupg.org/gph/en/manual/r1616.html + default: None + type: path + +author: + - Rinck H. Sonnenberg (r.sonnenberg@netson.nl) +''' + +EXAMPLES = ''' +# install key from keyfile on target host and set trust level to 5 +- name: add key(s) from file and set trust + gpg_key: + file: "/tmp/testkey.asc" + trust: '5' + +# make sure all keys in a file are NOT present on the keychain +- name: remove keys inside file from the keychain + gpg_key: + file: "/tmp/testkey.asc" + state: absent + +# install keys on the target host from a keyfile on the ansible master +- name: install keys on the target host from a keyfile on the ansible master + gpg_key: + content: "{{ lookup('file', '/my/tmp/file/on/host') }}" + +# alternatively, you can simply provide the key contents directly +- name: install keys from key contents + content: "-----BEGIN PGP PUBLIC KEY BLOCK-----........." + +# install key from keyserver on target machine +- name: install key from default keyserver on target machine + gpg_key: + fpr: 0D69E11F12BDBA077B3726AB4E1F799AA4FF2279 + +# install key from keyserver on target machine and set trust level +- name: install key from alternate keyserver on target machine and set trust level 5 + gpg_key: + fpr: 0D69E11F12BDBA077B3726AB4E1F799AA4FF2279 + keyserver: eu.pool.sks-keyservers.net + trust: '5' + +# delete a key from the target machine +- name: remove a key from the target machine + gpg_key: + fpr: 0D69E11F12BDBA077B3726AB4E1F799AA4FF2279 + state: absent + +# get keyinfo for a specific key; will also return success if key not installed +- name: get keyinfo + gpg_key: + fpr: 0D69E11F12BDBA077B3726AB4E1F799AA4FF2279 + state: info + +# get keyinfo for all installed keys, public and secret +- name: get keyinfo for all keys + gpg_key: + fpr: '*' + state: info +''' + +RETURN = ''' +keys: + description: | + list of keys touched by the module; + list contains dicts of fingerprint, keytype, capabilities and trust level for each key + an exmaple output would looke like: + A0880EC90DD07F5968CEE3B6C6B3D8E7A7CD2528: + changed: false + creationdate: '1576698396' + curve_name: ed25519 + expirationdate: '' + fingerprint: A0880EC90DD07F5968CEE3B6C6B3D8E7A7CD2528 + hash_algorithm: '' + key_capabilities: cSC + key_length: '256' + keyid: C6B3D8E7A7CD2528 + pubkey_algorithm: Ed25519 + state: present + trust_level: u + trust_level_desc: The key is ultimately trusted + type: pub + userid: 'somekey ' + If you set the state to absent, and the key was already absent, obviously + not all info will be available; it would look similar to: + A0880EC90DD07F5968CEE3B6C6B3D8E7A7CD2528: + changed: false + fingerprint: A0880EC90DD07F5968CEE3B6C6B3D8E7A7CD2528 + state: absent + type: list + returned: always +debug: + description: contains debug information + type: list + returned: when verbosity >= 2 +''' + +import re +import os +import time +from ansible.module_utils.basic import AnsibleModule +from packaging import version + +# class to import GPG keys +class GpgKey(object): + + + def __init__(self, module): + """ + init method + """ + # set ansible module + self.module = module + self.debugmsg = [] + self.installed_keys = {} + self.changed = False + + # seed the result dict in the object + # we primarily care about changed and state + # change is if this module effectively modified the target + # state will include any data that you want your module to pass back + # for consumption, for example, in a subsequent task + self.result = dict( + changed=False, + keys={}, + msg="", + ) + + # set gpg binary none was provided + if not self.module.params["gpgbin"] or self.module.params["gpgbin"] is None: + self.module.params["gpgbin"] = self.module.get_bin_path('gpg') + + + def _vv(self, msg): + """ + debug info + """ + # add debug message + self.debugmsg.append("{}".format(msg)) + + + def has_method(self, name): + """ + method to check if other methods exist + """ + return callable(getattr(self, name, None)) + + + def run(self): + """ + run module with given parameters + """ + # check versions of gnupg and libgcrypt + # check homedir + self.check_versions() + self.check_homedir() + + # determine and run action + if self.module.params["file"]: + run_action = "file" + elif self.module.params["fpr"]: + run_action = "fpr" + elif self.module.params["content"]: + run_action = "content" + else: + self.module.fail_json(msg="You shouldn't be here; no valid action could be determined") + + # determine action and method + run_state = self.module.params["state"] + run_method = "run_{}_{}".format(run_action, run_state) + self._vv("determined action [{}] with state [{}]".format(run_action, run_state)) + + # always check installed keys first + self.check_installed_keys() + #self.result["installed_keys"] = self.installed_keys + + # check if run method exists, and if not fail with an error + if self.has_method(run_method): + getattr(self, run_method)() + else: + self.module.fail_json(msg="Action [{}] is not supported with state [{}]".format(run_action, run_state)) + + # check verbosity and add debug messages + if self.module._verbosity >= 2: + self.result['debug'] = "\n".join(self.debugmsg) + + # return result + return self.result + + + def run_file_present(self): + """ + import key from file + """ + # first, check if the file is OK + keyinfo = self.check_file() + + self._vv("import new keys from file") + + # import count + impcnt = 0 + trucnt = 0 + + # then see if the key is already installed + # fk = file key + # ik = installed key + for index, fk in enumerate(keyinfo["keys"]): + + # check expiration by checking trust + if fk["trust_level"] in ['i','d','r','e']: + self.module.fail_json(msg="key is either expired or invalid [trust={}] [expiration={}]".format(fk["trust_level"], fk["expirationdate"])) + + # check if key is installed + installed = False + for ik in self.installed_keys["keys"]: + if (fk["fingerprint"] == ik["fingerprint"] and + fk["type"] == ik["type"] and + fk["key_capabilities"] == ik["key_capabilities"] + ): + self._vv("fingerprint [{}] already installed".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "present" + keyinfo["keys"][index]["changed"] = False + installed = True + + # check trust + if not self.compare_trust(fk["trust_level"], self.module.params["trust"]): + + # update trust level + self.set_trust(fk["fingerprint"], self.module.params["trust"]) + trucnt += 1 + + # get trust level as displayed by gpg + tru_level, tru_desc = self.get_trust(self.module.params["trust"]) + keyinfo["keys"][index]["changed"] = True + keyinfo["keys"][index]["trust_level"] = tru_level + keyinfo["keys"][index]["trust_level_desc"] = tru_desc + + continue + + if not installed: + + self._vv("fingerprint [{}] not yet installed".format(fk["fingerprint"])) + + # import file + cmd = self.prepare_command("file", "present") + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self._vv("fingerprint [{}] successfully imported".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "present" + keyinfo["keys"][index]["changed"] = True + impcnt += 1 + + # check trust + if not self.compare_trust(fk["trust_level"], self.module.params["trust"]): + + # update trust level + self.set_trust(fk["fingerprint"], self.module.params["trust"]) + trucnt += 1 + + # get trust level as displayed by gpg + tru_level, tru_desc = self.get_trust(self.module.params["trust"]) + keyinfo["keys"][index]["changed"] = True + keyinfo["keys"][index]["trust_level"] = tru_level + keyinfo["keys"][index]["trust_level_desc"] = tru_desc + + # set keyinfo + self.set_keyinfo(keyinfo) + + # check import count + if impcnt > 0 or trucnt > 0: + self.result["changed"] = True + + # set message and return + self.result["msg"] = "[{}] keys were imported; [{}] trust levels updated".format(impcnt, trucnt) + return True + + + def run_file_absent(self): + """ + remove key(s) present in file + """ + # first, check if the file is OK + keyinfo = self.check_file() + + self._vv("remove keys identified in file") + + # key count + keycnt = 0 + + # then see if the key is installed or not + # fk = file key + # ik = installed key + for index, fk in enumerate(keyinfo["keys"]): + installed = False + for ik in self.installed_keys["keys"]: + if (fk["fingerprint"] == ik["fingerprint"] and + fk["type"] == ik["type"] and + fk["key_capabilities"] == ik["key_capabilities"] + ): + installed = True + continue + + if not installed: + self._vv("fingerprint [{}] not installed; nothing to remove".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "absent" + keyinfo["keys"][index]["changed"] = False + + else: + + self._vv("fingerprint [{}] installed; will be removed".format(fk["fingerprint"])) + + # remove file + cmd = self.prepare_command("file", "absent") + + # add fingerprint as argument + cmd += [fk["fingerprint"]] + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self._vv("fingerprint [{}] successfully removed".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "absent" + keyinfo["keys"][index]["changed"] = True + keycnt += 1 + + # re-run check installed command to prevent attempting to remove same + # fingerprint again (for example after removing pub/sec counterpart + # with the same fpr + self.check_installed_keys() + + # set keyinfo + self.set_keyinfo(keyinfo) + + # check import count + if keycnt > 0: + self.result["changed"] = True + + # return + self.result["msg"] = "[{}] keys were removed".format(keycnt) + return True + + + def run_file_info(self): + """ + method to only retrive current status of keys + wether from file, content or fpr + """ + # first, check if the file is OK + keyinfo = self.check_file() + + self._vv("showing key info from file") + + # then see if the key is already installed + # fk = file key + # ik = installed key + for index, fk in enumerate(keyinfo["keys"]): + + # check if key is installed + installed = False + for ik in self.installed_keys["keys"]: + if (fk["fingerprint"] == ik["fingerprint"] and + fk["type"] == ik["type"] and + fk["key_capabilities"] == ik["key_capabilities"] + ): + self._vv("fingerprint [{}] installed".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "present" + keyinfo["keys"][index]["changed"] = False + installed = True + continue + + if not installed: + # set state + self._vv("fingerprint [{}] not installed".format(fk["fingerprint"])) + keyinfo["keys"][index]["state"] = "absent" + keyinfo["keys"][index]["changed"] = False + + # set keyinfo + self.set_keyinfo(keyinfo) + + # set message and return + return True + + + def run_content_present(self): + """ + import keys from content + """ + # prepare content + filename = self.prepare_content(self.module.params["content"]) + + # set file parameter and run file present + self.module.params["file"] = filename + self.run_file_present() + + # delete content + self.delete_content(filename) + + + def run_content_absent(self): + """ + remove keys from content + """ + # prepare content + filename = self.prepare_content(self.module.params["content"]) + + # set file parameter and run file present + self.module.params["file"] = filename + self.run_file_absent() + + # delete content + self.delete_content(filename) + + + def run_content_info(self): + """ + get key info from content + """ + # prepare content + filename = self.prepare_content(self.module.params["content"]) + + # set file parameter and run file present + self.module.params["file"] = filename + self.run_file_info() + + # delete content + self.delete_content(filename) + + + def run_fpr_present(self): + """ + import key from keyserver + """ + self._vv("import new keys from keyserver") + + # set fpr shorthand + fpr = self.module.params["fpr"] + + # set base values + installed = False + impcnt = 0 + trucnt = 0 + keyinfo = { + 'fprs': [], + 'keys': [], + } + + # check if key is installed + for ik in self.installed_keys["keys"]: + + if (fpr == ik["fingerprint"]): + + # set keyinfo + self._vv("fingerprint [{}] already installed".format(fpr)) + keyinfo["fprs"].append(fpr) + keyinfo["keys"].append(ik) + keyinfo["keys"][0]["state"] = "present" + keyinfo["keys"][0]["changed"] = False + installed = True + + # check trust + if not self.compare_trust(ik["trust_level"], self.module.params["trust"]): + + # update trust level + self.set_trust(fpr, self.module.params["trust"]) + trucnt += 1 + + # get trust level as displayed by gpg + tru_level, tru_desc = self.get_trust(self.module.params["trust"]) + keyinfo["keys"][0]["changed"] = True + keyinfo["keys"][0]["trust_level"] = tru_level + keyinfo["keys"][0]["trust_level_desc"] = tru_desc + + continue + + if not installed: + + self._vv("fingerprint [{}] not yet installed".format(fpr)) + + # import file + cmd = self.prepare_command("fpr", "present") + cmd += [fpr] + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self._vv("fingerprint [{}] successfully imported from keyserver".format(fpr)) + + # get info from specific key; keyservers only contain public keys + # so no point in checking the secret keys + cmd = self.prepare_command("check", "installed_public") + cmd += [fpr] + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + keyinfo = self.process_colons(stdout) + + # check expiration by checking trust + if keyinfo["keys"][0]["trust_level"] in ['i','d','r','e']: + # deleted the expired key and fail + cmd = self.prepare_command("fpr", "absent") + cmd += [fpr] + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self.module.fail_json(msg="key is either expired or invalid [trust={}] [expiration={}]".format(keyinfo["keys"][0]["trust_level"], keyinfo["keys"][0]["expirationdate"])) + + # update key info + keyinfo["keys"][0]["state"] = "present" + keyinfo["keys"][0]["changed"] = True + impcnt += 1 + + # check trust + if not self.compare_trust(keyinfo["keys"][0]["trust_level"], self.module.params["trust"]): + + # update trust level + self.set_trust(fpr, self.module.params["trust"]) + trucnt += 1 + + # get trust level as displayed by gpg + tru_level, tru_desc = self.get_trust(self.module.params["trust"]) + keyinfo["keys"][0]["changed"] = True + keyinfo["keys"][0]["trust_level"] = tru_level + keyinfo["keys"][0]["trust_level_desc"] = tru_desc + + # set keyinfo + self.set_keyinfo(keyinfo) + + # check import count + if impcnt > 0 or trucnt > 0: + self.result["changed"] = True + + # set message and return + self.result["msg"] = "[{}] keys were imported; [{}] trust levels updated".format(impcnt, trucnt) + return True + + + def run_fpr_absent(self): + """ + remove key(s) + """ + self._vv("delete keys based on fingerprint") + + # set fpr shorthand + fpr = self.module.params["fpr"] + + # set base values + installed = False + keycnt = 0 + keyinfo = { + 'fprs': [], + 'keys': [], + } + + # see if the key is installed or not + # ik = installed key + for ik in self.installed_keys["keys"]: + if fpr == ik["fingerprint"]: + if ("state" in ik and ik["state"] != "absent") or ("state" not in ik): + keyinfo["fprs"].append(fpr) + keyinfo["keys"].append(ik) + installed = True + continue + + if not installed: + + self._vv("fingerprint [{}] not installed; nothing to remove".format(fpr)) + key = {} + key[fpr] = { + "state" : "absent", + "changed" : False, + "fingerprint" : fpr, + } + keyinfo["fprs"].append(fpr) + keyinfo["keys"].append(key) + + else: + + self._vv("fingerprint [{}] installed; will be removed".format(fpr)) + + # remove file + cmd = self.prepare_command("fpr", "absent") + + # add fingerprint as argument + cmd += [fpr] + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self._vv("fingerprint [{}] successfully removed".format(fpr)) + keyinfo["keys"][0]["state"] = "absent" + keyinfo["keys"][0]["changed"] = True + keycnt += 1 + + # re-run check installed command to prevent attempting to remove same + # fingerprint again (for example after removing pub/sec counterpart + # with the same fpr + self.check_installed_keys() + + # set keyinfo + self.set_keyinfo(keyinfo) + + # check import count + if keycnt > 0: + self.result["changed"] = True + + # return + self.result["msg"] = "[{}] keys were removed".format(keycnt) + return True + + + def run_fpr_latest(self): + """ + get the latest key from the keyserver + """ + self._vv("get latest key from keyserver") + + # set fpr shorthand + fpr = self.module.params["fpr"] + + # set base values + installed = False + updated = False + updcnt = 0 + trucnt = 0 + keyinfo = { + 'fprs': [], + 'keys': [], + } + + # check if key is installed + for ik in self.installed_keys["keys"]: + + if (fpr == ik["fingerprint"]): + + # set keyinfo + self._vv("fingerprint [{}] installed; updating from server".format(fpr)) + keyinfo["fprs"].append(fpr) + keyinfo["keys"].append(ik) + keyinfo["keys"][0]["state"] = "present" + keyinfo["keys"][0]["changed"] = False + installed = True + continue + + if not installed: + + self._vv("fingerprint [{}] not yet installed; install first".format(fpr)) + + # import from keyserver + self.run_fpr_present() + return True + + else: + + self._vv("fetching updates from keyserver") + + # get updates from keyserver + cmd = self.prepare_command("fpr", "latest") + cmd += [fpr] + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + + # see if any updates were downloaded or not + # for some reason, gpg outputs these messages to stderr + updated = re.search('gpg:\s+unchanged: 1\n', stderr) is None + if updated: + updcnt += 1 + + # if key was updated, refresh info + if updated: + + self._vv("key was updated on server") + + # get info from specific key; keyservers only contain public keys + # so no point in checking the secret keys + cmd = self.prepare_command("check", "installed_public") + cmd += [fpr] + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + keyinfo = self.process_colons(stdout) + + # check expiration by checking trust + if keyinfo["keys"][0]["trust_level"] in ['i','d','r','e']: + # deleted the expired key and fail + cmd = self.prepare_command("fpr", "absent") + cmd += [fpr] + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + self.module.fail_json(msg="key is either expired or invalid [trust={}] [expiration={}]".format(keyinfo["keys"][0]["trust_level"], keyinfo["keys"][0]["expirationdate"])) + + # update key info + keyinfo["keys"][0]["state"] = "present" + keyinfo["keys"][0]["changed"] = True + + # check trust + if not self.compare_trust(keyinfo["keys"][0]["trust_level"], self.module.params["trust"]): + + # update trust level + self.set_trust(fpr, self.module.params["trust"]) + + # get trust level as displayed by gpg + tru_level, tru_desc = self.get_trust(self.module.params["trust"]) + keyinfo["keys"][0]["changed"] = True + keyinfo["keys"][0]["trust_level"] = tru_level + keyinfo["keys"][0]["trust_level_desc"] = tru_desc + trucnt += 1 + + # set keyinfo + self.set_keyinfo(keyinfo) + + # check import count + if updcnt > 0 or trucnt > 0: + self.result["changed"] = True + + # set message and return + self.result["msg"] = "[{}] keys were updated; [{}] trust levels updated".format(updcnt, trucnt) + return True + + + def run_fpr_info(self): + """ + method to only return current key info + will never report changed as it doesn't change anything on the target + """ + # frp shorthand + fpr = self.module.params["fpr"] + keycount = 0 + + # check if the request is for a single key or all + if fpr == "*": + keyinfo = self.installed_keys + keycount = len(self.installed_keys["keys"]) + + else: + # then see if the key is already installed + # ik = installed key + installed = False + keycount = 1 + keyinfo = { + "fprs": [], + "keys": [], + } + + for ik in self.installed_keys["keys"]: + if (fpr == ik["fingerprint"]): + self._vv("fingerprint [{}] installed".format(fpr)) + keyinfo["fprs"].append(fpr) + keyinfo["fprs"].append(ik) + keyinfo["keys"][0]["state"] = "present" + keyinfo["keys"][0]["changed"] = False + installed = True + continue + + if not installed: + # set state + self._vv("fingerprint [{}] not installed".format(fpr)) + keyinfo["fprs"].append(fpr) + keyinfo["keys"].append({}) + keyinfo["keys"][0]["fingerprint"] = fpr + keyinfo["keys"][0]["state"] = "absent" + keyinfo["keys"][0]["changed"] = False + + # set keyinfo + self.set_keyinfo(keyinfo) + self.result["msg"] = "listing info for [{}] key(s)".format(keycount) + + + def prepare_content(self, content): + """ + prepare content + """ + # create temporary file and write contents + filename = "tmp-gpg-{}.asc".format(time.time()) + self._vv("writing content to temporary file [{}]".format(filename)) + tmpfile = open("{}".format(filename),"w+") + tmpfile.write(content) + tmpfile.close() + + # return filename + return filename + + + def delete_content(self, filename): + """ + delete temporary content + """ + # cleanup + self._vv("deleting temporary file [{}]".format(filename)) + os.remove(filename) + + + def prepare_command(self, action, state): + """ + prepare any gpg command + """ + # set base command + cmd = [self.module.params["gpgbin"]] + + # determine dry run / check mode + if self.module.check_mode: + cmd.append("--dry-run") + + # determine if homedir was set + if self.module.params["homedir"]: + cmd.append("--homedir") + cmd.append(self.module.params["homedir"]) + + # check versions + if action == "check" and state == "versions": + args = ["--version"] + + # check installed public keys + if action == "check" and state == "installed_public": + args = [ + "--with-colons", + "--list-keys", + ] + + # check installed secret keys + if action == "check" and state == "installed_secret": + args = [ + "--with-colons", + "--list-secret-keys", + ] + + # check file + if action == "check" and state == "file": + args = [ + "--with-colons", + "--import-options", + "show-only", + "--import", + self.module.params["file"], + ] + + # file present + if action == "file" and state == "present": + args = [ + "--import", + self.module.params["file"], + ] + + # file absent + if action == "file" and state == "absent": + args = [ + "--batch", + "--yes", + "--delete-secret-and-public-key", + ] + + # set ownertrust + if action == "set" and state == "trust": + args = ["--import-ownertrust"] + + # fpr present + if action == "fpr" and state == "present": + args = ["--recv-keys"] + + # determine if keyserver + if self.module.params["keyserver"]: + cmd.append("--keyserver") + cmd.append(self.module.params["keyserver"]) + + # fpr absent + if action == "fpr" and state == "absent": + args = [ + "--batch", + "--yes", + "--delete-secret-and-public-key", + ] + + # fpr latest + if action == "fpr" and state == "latest": + args = ["--refresh-keys"] + + # determine if keyserver + if self.module.params["keyserver"]: + cmd.append("--keyserver") + cmd.append(self.module.params["keyserver"]) + + # merge cmd and args and return + cmd += args + self._vv("running command [{}]".format(" ".join(cmd))) + return cmd + + + def check_versions(self): + """ + function to verify we have the right gnupg2 version + """ + self._vv("checking gnupg and libgcrypt versions") + + # set command + cmd = self.prepare_command("check", "versions") + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + + # stdout lines - run_command returns a single string and we need the first and second line only + lines = stdout.splitlines() + + # find gpg version + regex_gpg = r"gpg\s+\(GnuPG[^)]*\)\s+(\d+\.\d+\.?\d*)$" + match_gpg = re.search(regex_gpg, lines[0]) + + # sanity check + if match_gpg is None or match_gpg.group(1) is None: + self.module.fail_json(msg="could not find a valid gpg version number in string [{}]".format(lines[0])) + + # find libgcrypt version + regex_libgcrypt = r"libgcrypt\s+(\d+\.\d+\.?\d*)" + match_libgcrypt = re.match(regex_libgcrypt, lines[1]) + + # sanity check + if match_libgcrypt is None or match_libgcrypt.group(1) is None: + self.module.fail_json(msg="could not find a valid libgcrypt version number in string [{}]".format(lines[1])) + + # check versions + versions = {'gpg' : match_gpg.group(1), + 'libgcrypt' : match_libgcrypt.group(1), + } + req_gpg = '2.1.17' + req_libgcrypt = '1.8.1' + + # display minimum versions + self._vv("gpg_key module requires at least gnupg version [{}] and libgcrypt version [{}]".format(versions['gpg'], versions['libgcrypt'])) + + # sanity check + if version.parse(versions['gpg']) < version.parse(req_gpg) or version.parse(versions['libgcrypt']) < version.parse(req_libgcrypt): + self.module.fail_json(msg="gpg version [{}] and libgcrypt version [{}] are required; [{}] and [{}] given".format(req_gpg, req_libgcrypt, versions['gpg'], versions['libgcrypt'])) + else: + self._vv("gnupg version [{}] and libgcrypt version [{}] detected".format(versions['gpg'], versions['libgcrypt'])) + + return True + + + def check_installed_keys(self): + """ + get list of keyfiles from current gpg homedir + """ + self._vv("checking installed public keys on target host") + + # set command + cmd = self.prepare_command("check", "installed_public") + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + + # get public key info + pubkeyinfo = self.process_colons(stdout) + + self._vv("found a total of [{}] public keys on target host".format(len(pubkeyinfo["fprs"]))) + + self._vv("checking installed secret keys on target host") + + # set command + cmd = self.prepare_command("check", "installed_secret") + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + + # get public key info + seckeyinfo = self.process_colons(stdout) + + self._vv("found a total of [{}] secret keys on target host".format(len(seckeyinfo["fprs"]))) + + # merge keys + keyinfo = { + 'fprs': pubkeyinfo["fprs"]+seckeyinfo["fprs"], + 'keys': pubkeyinfo["keys"]+seckeyinfo["keys"], + } + + # remove any duplicate fingerprints which may occur in both pub and sec keys + keyinfo["fprs"] = list(dict.fromkeys(keyinfo["fprs"])) + + # set keyinfo + self.installed_keys = keyinfo + + + def check_homedir(self): + """ + check homedir + """ + # check if homedir exists, if not, fail + if self.module.params["homedir"] and not os.path.isdir(self.module.params["homedir"]): + self.module.fail_json(msg="given homedir [{}] does not exist or not accessible by current ansible user".format(self.module.params["homedir"])) + + self._vv("homedir set to [{}]".format(self.module.params["homedir"])) + + return True + + + def check_file(self): + """ + check if param file exists on target machine + check if file is a valid keyfile + check for fingerprints + """ + self._vv("checking keyfile on target host") + + # sanity check + if not os.path.isfile(self.module.params["file"]): + self.module.fail_json(msg="the keyfile [{}] does not exist on the target machine".format(self.module.params["file"])) + else: + self._vv("keyfile [{}] exists on target host".format(self.module.params["file"])) + + # get key info from file + cmd = self.prepare_command("check", "file") + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + keyinfo = self.process_colons(stdout) + + return keyinfo + + + def process_colons(self, cinfo): + """ + fetch key information from colon output + """ + # + # SAMPLE DATA + # + # sec:u:256:22:41343326127FD34F:1566067845:::u:::cC:::+::ed25519:::0: + # fpr:::::::::0D18E4B6B2698560729D00CE41343326127FD34F: + # grp:::::::::54AA357FD85BA4D4B7CE86016A3734F00B1BDD07: + # uid:u::::1566067845::00B9F0DC33EE293CC1E687FFA54A5EA805FD78F8::testing145 (TESTINGCOMM) ::::::::::0: + # + + # + # line types + # + # *** Field 1 - Type of record + # + # - pub :: Public key + # - crt :: X.509 certificate + # - crs :: X.509 certificate and private key available + # - sub :: Subkey (secondary key) + # - sec :: Secret key + # - ssb :: Secret subkey (secondary key) + # - uid :: User id + # - uat :: User attribute (same as user id except for field 10). + # - sig :: Signature + # - rev :: Revocation signature + # - rvs :: Revocation signature (standalone) [since 2.2.9] + # - fpr :: Fingerprint (fingerprint is in field 10) + # - pkd :: Public key data [*] + # - grp :: Keygrip + # - rvk :: Revocation key + # - tfs :: TOFU statistics [*] + # - tru :: Trust database information [*] + # - spk :: Signature subpacket [*] + # - cfg :: Configuration data [*] + # + # Records marked with an asterisk are described at [[*Special%20field%20formats][*Special fields]]. + # + + # + # *** Field 12 - Key capabilities + # + # The defined capabilities are: + # + # - e :: Encrypt + # - s :: Sign + # - c :: Certify + # - a :: Authentication + # - ? :: Unknown capability + # + # A key may have any combination of them in any order. In addition + # to these letters, the primary key has uppercase versions of the + # letters to denote the _usable_ capabilities of the entire key, and + # a potential letter 'D' to indicate a disabled key. + # + + # + # FIELD TYPES: + # + # - Field 1 - Type of record + # - Field 2 - Validity + # - Field 3 - Key length + # - Field 4 - Public key algorithm + # - Field 5 - KeyID + # - Field 6 - Creation date + # - Field 7 - Expiration date + # - Field 8 - Certificate S/N, UID hash, trust signature info + # - Field 9 - Ownertrust + # - Field 10 - User-ID + # - Field 11 - Signature class + # - Field 12 - Key capabilities + # - Field 13 - Issuer certificate fingerprint or other info + # - Field 14 - Flag field + # - Field 15 - S/N of a token + # - Field 16 - Hash algorithm + # - Field 17 - Curve name + # - Field 18 - Compliance flags + # - Field 19 - Last update + # - Field 20 - Origin + # - Field 21 - Comment + # + + # determine the correct line + main_lines = ['sec','ssb','pub','sub'] + follow_lines = ['fpr','grp','uid'] + + # indexes start at 0 + # main parts are for main_lines only + mainparts = { + 'type' : 0, + 'trust_level' : 1, + 'key_length' : 2, + 'pubkey_algorithm' : 3, + 'keyid' : 4, + 'creationdate' : 5, + 'expirationdate' : 6, + 'key_capabilities' : 11, + 'hash_algorithm' : 15, + 'curve_name' : 16, + } + + # indexes start at 0 + # follow parts for follow_lines only + followparts = { + 'type' : 0, + 'userid' : 9, # this is the fingerprint for fpr records and the keygrip for grp records + } + + # + # 9.1. Public-Key Algorithms + # + # ID Algorithm + # -- --------- + # 1 - RSA (Encrypt or Sign) [HAC] + # 2 - RSA Encrypt-Only [HAC] + # 3 - RSA Sign-Only [HAC] + # 16 - Elgamal (Encrypt-Only) [ELGAMAL] [HAC] + # 17 - DSA (Digital Signature Algorithm) [FIPS186] [HAC] + # 18 - Reserved for Elliptic Curve + # 19 - Reserved for ECDSA + # 20 - Reserved (formerly Elgamal Encrypt or Sign) + # 21 - Reserved for Diffie-Hellman (X9.42, + # as defined for IETF-S/MIME) + # 22 - Ed25519 + # 100 to 110 - Private/Experimental algorithm + # + pubkeys = { + '1' : 'RSA (Encrypt or Sign)', + '2' : 'RSA Encrypt-Only', + '3' : 'RSA Sign-Only', + '16' : 'Elgamal (Encrypt-Only)', + '17' : 'DSA [FIPS186]', + '18' : 'Cv25519', + '22' : 'Ed25519', + } + + # + # 2. Field: A letter describing the calculated trust. This is a single + # letter, but be prepared that additional information may follow + # in some future versions. (not used for secret keys) + # + # o = Unknown (this key is new to the system) + # i = The key is invalid (e.g. due to a missing self-signature) + # d = The key has been disabled + # r = The key has been revoked + # e = The key has expired + # - = Unknown trust (i.e. no value assigned) + # q = Undefined trust; '-' and 'q' may safely be treated as the same value for most purposes + # n = Don't trust this key at all + # m = There is marginal trust in this key + # f = The key is full trusted. + # u = The key is ultimately trusted; this is only used for + # keys for which the secret key is also available. + # + trustlevels = { + 'o' : 'Unknown/new', + 'i' : 'The key is invalid', + 'd' : 'The key has been disabled', + 'r' : 'The key has been revoked', + 'e' : 'The key has expired', + '-' : 'Unknown trust', + 'q' : 'Undefined trust', + 'n' : 'Dont trust this key at all', + 'm' : 'There is marginal trust in this key', + 'f' : 'The key is fully trusted', + 'u' : 'The key is ultimately trusted', + } + + # set list of keys and list of fingerprints + keys = [] + fprs = [] + + # set empty key dict + curKey = {} + + # loop through lines + for l in cinfo.splitlines(): + + # split line into pieces + pieces = l.split(":") + + # get current line type + curType = pieces[mainparts.get('type')] + + # check for usage/capabilities + if curType in main_lines: + + # check if curKey has values; if so add them to keys list first + if "type" in curKey: + self._vv("found [{}] key with fingerprint [{}]".format(curKey["type"], curKey["fingerprint"])) + keys.append(curKey) + + # get pubkey algorithm + p = pieces[mainparts.get('pubkey_algorithm')] + z = pubkeys.get(p) if p is not None else '' + + # get trustlevel description + p = pieces[mainparts.get('trust_level')] + t = trustlevels.get(p) if p is not None else '' + + curKey = { + 'type': pieces[mainparts.get('type')], + 'trust_level': pieces[mainparts.get('trust_level')], + 'trust_level_desc': t, + 'key_length': pieces[mainparts.get('key_length')], + 'pubkey_algorithm': z, + 'keyid': pieces[mainparts.get('keyid')], + 'creationdate': pieces[mainparts.get('creationdate')], + 'expirationdate': pieces[mainparts.get('expirationdate')], + 'key_capabilities': pieces[mainparts.get('key_capabilities')], + 'hash_algorithm': pieces[mainparts.get('hash_algorithm')], + 'curve_name': pieces[mainparts.get('curve_name')], + } + + elif curType in follow_lines: + + # check follow line type + if curType == "fpr": + curKey["fingerprint"] = pieces[followparts.get('userid')] + fprs.append(curKey["fingerprint"]) + elif curType == "grp": + curKey["keygrip"] = pieces[followparts.get('userid')] + elif curType == "uid": + curKey["userid"] = pieces[followparts.get('userid')] + + # if we make it here we have encountered an unknown linetype + # we should add the key info we have gathered so far to the keylist + # and reset the key dict so it won't get added again in case more + # keys will follow in the next lines + else: + if "type" in curKey: + self._vv("found [{}] key with fingerprint [{}]".format(curKey["type"], curKey["fingerprint"])) + keys.append(curKey) + curKey = {} + + # after the last line, see if any keys remain which need to be added + if "type" in curKey: + self._vv("found [{}] key with fingerprint [{}]".format(curKey["type"], curKey["fingerprint"])) + keys.append(curKey) + + # + # set and return results + # + return { + 'keys': keys, + 'fprs': fprs, + } + + + def compare_trust(self, trust1, trust2): + """ + method to compare 2 trust levels + """ + # check if we are managing trust + if not self.module.params["manage_trust"]: + self._vv("we're not managing trust") + return True + + # + # trust level returned by GnuPG + # 'o' : 'Unknown/new', + # 'i' : 'The key is invalid', + # 'd' : 'The key has been disabled', + # 'r' : 'The key has been revoked', + # 'e' : 'The key has expired', + # '-' : 'Unknown trust', + # 'q' : 'Undefined trust', + # 'n' : 'Dont trust this key at all', + # 'm' : 'There is marginal trust in this key', + # 'f' : 'The key is fully trusted', + # 'u' : 'The key is ultimately trusted', + # + trust_map = { + 'o' : "0", + 'i' : "0", + 'd' : "0", + 'r' : "0", + 'e' : "0", + '-' : "1", + 'q' : "1", + 'n' : "2", + 'm' : "3", + 'f' : "4", + 'u' : "5", + } + + # convert trust if necessary + if trust1 in trust_map.keys(): + trust1 = trust_map[trust1] + if trust2 in trust_map.keys(): + trust2 = trust_map[trust2] + + self._vv("comparing trust [{}] and [{}]".format(trust1, trust2)) + + # compare trust + return trust1 == trust2 + + + def get_trust(self, trust): + """ + method to get trust indicator from value + """ + gpg_map = { + '1' : '-', + '2' : 'n', + '3' : 'm', + '4' : 'f', + '5' : 'u', + } + + trust_map = { + '-' : 'Unknown trust', + 'n' : 'Dont trust this key at all', + 'm' : 'There is marginal trust in this key', + 'f' : 'The key is fully trusted', + 'u' : 'The key is ultimately trusted', + } + + # return trust value + return gpg_map[trust], trust_map[gpg_map[trust]] + + + def set_trust(self, fingerprint, trust): + """ + method to set ownertrust + """ + # + # Trust | Description | Value ownertrust | Value with colons + # 1 | I don't know or won't say | 2 | -|q|o + # 2 | I do NOT trust | 3 | n + # 3 | I trust marginally | 4 | m + # 4 | I trust fully | 5 | f + # 5 | I trust ultimately | 6 | u + # + + self._vv("update trust level to [{}]".format(trust)) + + # IMPORTANT: please keep in mind that with trust levels other than 5 + # the keys you import will need to be signed by a fully trusted key, + # or be signed using the web of trust; see: + # Using trust to validate keys: https://www.gnupg.org/gph/en/manual/x334.html + # signing keys is not handled by this module and should be done by yourself + # setting the trust. + + # trust map + trust_map = { + '1' : '2', + '2' : '3', + '3' : '4', + '4' : '5', + '5' : '6', + } + + # create temporary owner trust file + # the newline at the end is required to prevent a 'gpg: line too long' error + content = "{}:{}:\n".format(fingerprint, trust_map[trust]) + filename = self.prepare_content(content) + + # prepare command + cmd = self.prepare_command("set", "trust") + cmd += [filename] + + # run subprocess + rc, stdout, stderr = self.module.run_command(args=cmd, check_rc=True) + + # delete content + self.delete_content(filename) + + # return + return True + + + def set_keyinfo(self, keyinfo): + """ + sets the keyinfo in an easy to process format + starting with the fingerprint as the key, + then the value is a dict with the key details + """ + self._vv("setting key info to return to playbook") + + # loop through keyinfo and set fprs as dict key + for key in keyinfo["keys"]: + if "fingerprint" in key: + self.result["keys"][key["fingerprint"]] = key + + +def main(): + + # define available arguments/parameters a user can pass to the module + module_args = dict( + fpr=dict(type='str', required=False), + keyserver=dict(type='str', default='keyserver.ubuntu.com'), + file=dict(type='path', required=False), + content=dict(type='str', required=False), + trust=dict(type='str', default='1', choices=['1','2','3','4','5']), + manage_trust=dict(type='bool', default=True), + state=dict(type='str', default='present', choices=['info', 'present', 'absent', 'latest']), + gpgbin=dict(type='path', default=None), + homedir=dict(type='path', default=None), + ) + + # set mutually exclusive params + mutually_exclusive = [ + ['fpr', 'file', 'content'], + ] + + # set at least one required field + required_one_of = [ + ['fpr', 'file', 'content'] + ] + + # the AnsibleModule object will be our abstraction working with Ansible + # this includes instantiation, a couple of common attr would be the + # args/params passed to the execution, as well as if the module + # supports check mode + module = AnsibleModule( + argument_spec=module_args, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + supports_check_mode=True + ) + + # run module + gpgkey = GpgKey(module) + result = gpgkey.run() + + # in the event of a successful module execution, you will want to + # simple AnsibleModule.exit_json(), passing the key/value results + module.exit_json(**result) + + + # if the user is working with this module in only check mode we do not + # want to make any changes to the environment, just return the current + # state with no modifications + if module.check_mode: + module.exit_json(**result) + + # module.get_bin_path / def get_bin_path + # module.run_command / def run_command + +if __name__ == '__main__': + main() diff --git a/roles/duplicity_client/defaults/main.yml b/roles/duplicity_client/defaults/main.yml index 7264489..5ac9eaa 100644 --- a/roles/duplicity_client/defaults/main.yml +++ b/roles/duplicity_client/defaults/main.yml @@ -1,2 +1,6 @@ --- -# defaults file for duplicity-client +# User to run duplicity things under +duplicity_client_user: "backup-user" + +# Paths to backup through duplicity +duplicity_client_backup_paths: [] \ No newline at end of file diff --git a/roles/duplicity_client/handlers/main.yml b/roles/duplicity_client/handlers/main.yml deleted file mode 100644 index 0ec2670..0000000 --- a/roles/duplicity_client/handlers/main.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -# handlers file for duplicity-client diff --git a/roles/duplicity_client/tasks/main.yml b/roles/duplicity_client/tasks/main.yml index c3679cb..3722842 100644 --- a/roles/duplicity_client/tasks/main.yml +++ b/roles/duplicity_client/tasks/main.yml @@ -1,2 +1,44 @@ --- -# tasks file for duplicity-client +- name: "Install required software on clients" + become: true + ansible.builtin.package: + name: "{{ item }}" + state: "present" + with_items: + - "acl" + +- name: "Create backup user on clients" + become: true + ansible.builtin.user: + name: "{{ duplicity_client_user }}" + +- name: "Deploy server ssh keys to clients" + become: true + ansible.posix.authorized_key: + user: "{{ duplicity_client_user }}" + state: "present" + key: "{{ item.duplicity_server_user_key }}" + with_items: "{{ groups['duplicityclient'] | flatten(levels=1) }}" + +- name: "Set default ACLs on backup data" + become: true + ansible.posix.acl: + path: "{{ item }}" + entity: "{{ duplicity_client_user }}" + etype: "user" + permissions: r-X + default: true + state: present + recursive: true + with_items: "{{ duplicity_client_backup_paths }}" + +- name: "Set read ACLs on existing backup data" + become: true + ansible.posix.acl: + path: "{{ item }}" + entity: "{{ duplicity_client_user }}" + etype: "user" + permissions: r-X + state: present + recursive: true + with_items: "{{ duplicity_client_backup_paths }}" diff --git a/roles/duplicity_client/tests/inventory b/roles/duplicity_client/tests/inventory deleted file mode 100644 index 878877b..0000000 --- a/roles/duplicity_client/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -localhost - diff --git a/roles/duplicity_client/tests/test.yml b/roles/duplicity_client/tests/test.yml deleted file mode 100644 index 37ba019..0000000 --- a/roles/duplicity_client/tests/test.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- hosts: localhost - remote_user: root - roles: - - duplicity-client diff --git a/roles/duplicity_client/vars/main.yml b/roles/duplicity_client/vars/main.yml deleted file mode 100644 index 604728b..0000000 --- a/roles/duplicity_client/vars/main.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -# vars file for duplicity-client diff --git a/roles/duplicity_server/defaults/main.yml b/roles/duplicity_server/defaults/main.yml index 1c91512..d5a147c 100644 --- a/roles/duplicity_server/defaults/main.yml +++ b/roles/duplicity_server/defaults/main.yml @@ -1,2 +1,6 @@ --- # defaults file for duplicity-server +duplicity_server_user: "backup-user" + +# The GnuPG key to use for encryption +duplicity_server_gnupg_fingerprint: "C05AD49B790BAC8E3B573B697B25171F921B9E57" \ No newline at end of file diff --git a/roles/duplicity_server/handlers/main.yml b/roles/duplicity_server/handlers/main.yml deleted file mode 100644 index 31eda2c..0000000 --- a/roles/duplicity_server/handlers/main.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -# handlers file for duplicity-server diff --git a/roles/duplicity_server/tasks/main.yml b/roles/duplicity_server/tasks/main.yml index 69ad448..4478d6f 100644 --- a/roles/duplicity_server/tasks/main.yml +++ b/roles/duplicity_server/tasks/main.yml @@ -1,144 +1,88 @@ --- -# - name: Install required software on servers -# become: true -# ansible.builtin.package: -# name: "{{ item }}" -# state: present -# with_items: -# - duplicity -# - sshfs -# - python3-packaging -# - acl +### BASIC SOFTWARE +- name: "Install required software on servers" + become: true + ansible.builtin.package: + name: "{{ item }}" + state: "present" + with_items: + - "duplicity" # Obviously needed for backup + - "sshfs" # TO be able to mount the remote directory + - "python3-packaging" # ??? For something + - "acl" # To manage the access control on the backup data -- name: Create backup user on servers +### SSH ACCESS (USER/KEYS) +- name: "Create backup user on servers" become: true ansible.builtin.user: name: "{{ duplicity_server_user }}" - generate_ssh_key: true - ssh_key_type: ed25519 + generate_ssh_key: true # We want to generate an ssh key to be able to configure the access on the clients later + ssh_key_type: "ed25519" + ssh_key_comment: "{{ duplicity_server_user }}@{{ ansible_hostname }} (generated by ansible)" + register: "duplicity_server_created_user" # Store the return value for the ssh key and home path -- name: Fetch server keys to local system +- name: "Store server ssh key as fact for later usage" + ansible.builtin.set_fact: + duplicity_server_user_key: "{{ duplicity_server_created_user['ssh_public_key'] }}" + +- name: "Fetch sshd fingerprints from clients" + ansible.builtin.slurp: + src: "/etc/ssh/ssh_host_ecdsa_key.pub" + delegate_to: "{{ item }}" + with_items: "{{ groups['duplicityclient'] | flatten(levels=1) }}" + changed_when: false + register: "duplicity_client_host_key" + +- name: "Register client host keys in server" become: true become_user: "{{ duplicity_server_user }}" - ansible.builtin.slurp: - src: ~/.ssh/id_ed25519.pub - register: duplicity_server_key - changed_when: false + ansible.builtin.known_hosts: + name: "{{ item.item }}" + key: "{{ item.item }} {{ item.content | b64decode }}" + with_items: "{{ duplicity_client_host_key.results }}" -# - name: "Deploy server ssh keys to clients" -# when: -# - duplicity_client -# - hostvars[item].duplicity_server is defined and hostvars[item].duplicity_server -# become: true -# ansible.posix.authorized_key: -# user: "{{ duplicity_client_user }}" -# state: "present" -# key: "{{ lookup('file', 'buffer/{{item}}-id_ed25519.pub') }}" -# loop: "{{ groups['duplicity'] }}" +### GNUPG ENCRYPTION +- name: "Ensure gnupg config dir" + become: true + become_user: "{{ duplicity_server_user }}" + ansible.builtin.command: + cmd: "gpg --list-keys" + creates: "{{ duplicity_server_created_user['home'] }}/.gnupg" -# - name: "Fetch sshd fingerprints from clients" -# when: duplicity_client -# ansible.builtin.fetch: -# src: "/etc/ssh/ssh_host_ecdsa_key.pub" -# dest: "buffer/{{ ansible_host }}-ssh_host_ecdsa_key.pub" -# flat: true -# changed_when: false +- name: "Install encryption key for backups" + become: true + become_user: "{{ duplicity_server_user }}" + de_enbewe.duplicity.gpg_key: + fpr: "{{ duplicity_server_gnupg_fingerprint }}" + keyserver: "hkps://keys.openpgp.org" + trust: 5 + homedir: "{{ duplicity_server_created_user['home'] }}/.gnupg" -# - name: "Register client host keys in server" -# when: -# - duplicity_server -# - hostvars[item].duplicity_client is defined and hostvars[item].duplicity_client -# become: true -# become_user: "{{ duplicity_server_user }}" -# ansible.builtin.known_hosts: -# name: "{{ item }}" -# key: "{{ item }} {{ lookup('file', 'buffer/{{item}}-ssh_host_ecdsa_key.pub') }}" -# loop: "{{ groups['duplicity'] }}" +### BACKUP SCRIPTS +- name: "Create backup script path" + become: true + ansible.builtin.file: + path: "{{ duplicity_server_created_user['home'] }}/scripts" + state: "directory" + owner: "{{ duplicity_server_user }}" + group: "{{ duplicity_server_user }}" + mode: "u=rwx,g=rx,o=rx" -# - name: "Test ssh connection from server to client" -# when: -# - duplicity_server -# - hostvars[item].duplicity_client is defined and hostvars[item].duplicity_client -# become: true -# become_user: "{{ duplicity_server_user }}" -# ansible.builtin.command: "ssh -o 'BatchMode yes' {{ duplicity_client_user }}@{{ item }} 'echo success'" -# changed_when: false -# loop: "{{ groups['duplicity'] }}" +- name: "Create backup scripts for clients" + become: true + become_user: "{{ duplicity_server_user }}" + ansible.builtin.template: + src: "backup-script.j2" + dest: "{{ duplicity_server_created_user['home'] }}/scripts/backup-{{ item }}.sh" + mode: "u=rwx,g=rx,o=rx" + with_items: "{{ groups['duplicityclient'] }}" -# - name: "Set default ACLs on backup data" -# when: duplicity_client -# become: true -# ansible.posix.acl: -# path: "{{ item }}" -# entity: "{{ duplicity_client_user }}" -# etype: "user" -# permissions: r-X -# default: true -# state: present -# recursive: true -# loop: "{{ duplicity_client_backup_paths }}" - -# - name: "Set read ACLs on existing backup data" -# when: duplicity_client -# become: true -# ansible.posix.acl: -# path: "{{ item }}" -# entity: "{{ duplicity_client_user }}" -# etype: "user" -# permissions: r-X -# state: present -# recursive: true -# loop: "{{ duplicity_client_backup_paths }}" - -# - name: "Ensure gnupg config dir" -# when: duplicity_server -# become: true -# become_user: "{{ duplicity_server_user }}" -# ansible.builtin.command: -# cmd: "gpg --list-keys" -# creates: "/home/{{ duplicity_server_user }}/.gnupg" - - -# - name: "Install encryption key for backups" -# when: duplicity_server -# become: true -# gpg_key: -# fpr: "C05AD49B790BAC8E3B573B697B25171F921B9E57" -# keyserver: "hkps://keys.openpgp.org" -# trust: "5" -# homedir: "/home/{{ duplicity_server_user }}/.gnupg" - -# - name: "Create backup script path" -# when: duplicity_server -# become: true -# ansible.builtin.file: -# path: "{{ duplicity_server_scriptdir }}" -# state: "directory" -# owner: "{{ duplicity_server_user }}" -# group: "{{ duplicity_server_user }}" -# mode: "u=rwx,g=rx,o=rx" - -# - name: "Create backup scripts for clients" -# when: -# - duplicity_server -# - hostvars[item].duplicity_client is defined and hostvars[item].duplicity_client -# become: true -# become_user: "{{ duplicity_server_user }}" -# ansible.builtin.template: -# src: "backup-script.j2" -# dest: "{{ duplicity_server_scriptdir }}/backup-{{ item }}.sh" -# mode: "u=rwx,g=rx,o=rx" -# loop: "{{ groups['duplicity'] }}" - -# - name: "Register cronjob for clients" -# when: -# - duplicity_server -# - hostvars[item].duplicity_client is defined and hostvars[item].duplicity_client -# become: true -# ansible.builtin.cron: -# name: "backup-{{ item }}" -# user: "{{ duplicity_server_user }}" -# job: "{{ duplicity_server_scriptdir }}/backup-{{ item }}.sh" -# minute: "{{ hostvars[item].duplicity_client_backup_minute }}" -# hour: "{{ hostvars[item].duplicity_client_backup_hour }}" -# loop: "{{ groups['duplicity'] }}" +- name: "Register cronjob for clients" + become: true + ansible.builtin.cron: + name: "backup-{{ item }}" + user: "{{ duplicity_server_user }}" + job: "{{ duplicity_server_created_user['home'] }}/scripts/backup-{{ item }}.sh" + minute: "{{ hostvars[item].duplicity_client_backup_minute | default(12) }}" + hour: "{{ hostvars[item].duplicity_client_backup_hour | default(1) }}" + with_items: "{{ groups['duplicityclient'] }}" diff --git a/roles/duplicity_server/templates/backup-script.j2 b/roles/duplicity_server/templates/backup-script.j2 new file mode 100644 index 0000000..feb4381 --- /dev/null +++ b/roles/duplicity_server/templates/backup-script.j2 @@ -0,0 +1,24 @@ +#!/bin/bash + +# Cleanup old backups +duplicity remove-all-inc-of-but-n-full 2 --force file://{{ duplicity_server_storage_root }}/{{item}} +duplicity remove-all-but-n-full 3 --force file://{{ duplicity_server_storage_root }}/{{item}} + +# Prepare mount directory +rm -rf {{ duplicity_server_mount_root }}/{{ item }} +mkdir -p {{ duplicity_server_mount_root }}/{{ item }} +cd {{ duplicity_server_mount_root }}/{{ item }} + +# mount the client directories through sshfs +{% for path in hostvars[item].duplicity_client_backup_paths %} + mkdir -p .{{ path }} + sshfs {{ hostvars[item].duplicity_client_user | default(duplicity_client_user) }}@{{ item }}:{{path}} .{{ path }} +{% endfor %} + +# Perform the backup +duplicity --full-if-older-than 1W --encrypt-key C05AD49B790BAC8E3B573B697B25171F921B9E57 . file://{{ duplicity_server_storage_root }}/{{item}} + +# Unmount the directories +{% for path in hostvars[item].duplicity_client_backup_paths %} + fusermount -u .{{ path }} +{% endfor %} diff --git a/roles/duplicity_server/tests/inventory b/roles/duplicity_server/tests/inventory deleted file mode 100644 index 878877b..0000000 --- a/roles/duplicity_server/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -localhost - diff --git a/roles/duplicity_server/tests/test.yml b/roles/duplicity_server/tests/test.yml deleted file mode 100644 index 1039d0c..0000000 --- a/roles/duplicity_server/tests/test.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- hosts: localhost - remote_user: root - roles: - - duplicity-server diff --git a/roles/duplicity_server/vars/main.yml b/roles/duplicity_server/vars/main.yml deleted file mode 100644 index e751025..0000000 --- a/roles/duplicity_server/vars/main.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -# vars file for duplicity-server