aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md229
-rw-r--r--molecule/default/tests/test_decryption_keys.py82
-rw-r--r--tasks/client_setup.yml70
-rw-r--r--tasks/server_setup.yml12
4 files changed, 310 insertions, 83 deletions
diff --git a/README.md b/README.md
index d556818..5281a01 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,4 @@
-Ansible Role: BorgBackup
-========================
+# Ansible Role: BorgBackup
An Ansible role that installs and configures a systemd service for BorgBackup on
a client and (delegated) server.
@@ -9,8 +8,7 @@ The documentation assumes basic knowledge about BorgBackup. You can get up to
speed with Borg by reading their excellent documentation on
<https://borgbackup.readthedocs.io>.
-How it works
-------------
+## How it works
```mermaid
sequenceDiagram
@@ -27,8 +25,7 @@ borg-client->>Ansible: Store decryption keys
borg-client-->borg-server: Do Backup
```
-Installation
-------------
+## Installation
via ansible-galaxy
@@ -54,8 +51,7 @@ ansible-galaxy role install git+https://github.com/kliwniloc/ansible-role-borgba
name: kliwniloc.borgbackup
```
-Role Variables
---------------
+## Role Variables
For more details, see: [`defaults/main.yml`](defaults/main.yml)
@@ -76,7 +72,7 @@ will default to `borg_server_host`.
```yaml
borg_server_host: "" # Required
borg_server_host_ssh_key: "" # Required
-borg_server_host_url: {{ borg_server_host }} # Optional
+borg_server_host_url: { { borg_server_host } } # Optional
```
The role creates a user for the Borg repositories on the Borg server. You can
@@ -121,6 +117,8 @@ your Borg backups, but it is not used by default.
> If you want true end-to-end encryption, you should configure a passphrase
> Read more: [BorgBackup Security](https://borgbackup.readthedocs.io/en/stable/internals/security.html#offline-key-security)
+<!-- TODO: Include keyfile backup mode -->
+
`borg_included_dirs` and `borg_excluded_dirs` finally picks what directories
will be backed up by Borg.
@@ -167,18 +165,17 @@ see man-page systemd.timer(5).
borg_backup_script_location: /usr/local/bin/run_borg_backup
borg_backup_timer_name: borg_backup
borg_backup_service_name: borg_backup
-borg_backup_argument: '{{ borg_server_host_url }}'
-borg_systemd_oncalendar: '*-*-* 02:00:00'
+borg_backup_argument: "{{ borg_server_host_url }}"
+borg_systemd_oncalendar: "*-*-* 02:00:00"
borg_systemd_accuracysec: 60min
```
-Example Playbooks
------------------
+## Example Playbooks
-Simple Playbook
+### Simple Playbook
```yaml
-- name: >
+- name: >-
Add Borg backup to the borg-client and save the
decryption keys alongside the playbook
hosts: borg-client
@@ -192,10 +189,10 @@ Simple Playbook
- /opt/project
```
-Multiple Backup servers
+### Multiple Backup Servers
```yaml
-- name: >
+- name: >-
Add Borg backup with multiple borg servers to the borg-client and
save the decryption keys alongside the playbook
hosts: borg-client
@@ -212,12 +209,202 @@ Multiple Backup servers
borg_server_host_ssh_key: ssh-rsa BBBBBBBB...
```
-Dependencies
-------------
+### Multiple Repositories on Single Server, Single Client (Multi-Instance)
+
+> [!NOTE]
+> **This is considered a beta feature**
+> This feature is still being developed and has some quirks around it.
+> We plan to release this properly on the next major version with some breaking
+> changes to make this less of a footgun. Until then, read this section
+> carefully.
+
+When a single client host needs to back up different data sets to the same Borg
+server (e.g., system configs and user data), you can run the role multiple times
+with different `borg_repo_name` values.
+
+**Important:** Each invocation must have a unique `borg_backup_argument` to create
+separate systemd timer units.
+
+<!-- TODO: (next major version): Change default borg_backup_argument to include
+ borg_repo_name for uniqueness:
+ "{{ borg_server_host_url }}-{{ borg_repo_name }}".
+ This will be a breaking change requiring migration of existing systemd
+ units. -->
+
+```yaml
+- name: Configure multi-instance backup (same host, different repos)
+ hosts: borg-client
+ vars:
+ borg_server_host: borg-server
+ borg_server_host_ssh_key: ssh-rsa AAAAAAAA...
+ borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml"
+
+ roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: configs
+ borg_backup_argument: configs
+ borg_compression: zstd
+ borg_included_dirs:
+ - /etc
+ borg_systemd_oncalendar: "*-*-* 02:00:00"
+
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: home-data
+ borg_backup_argument: home-data
+ borg_compression: lz4
+ borg_included_dirs:
+ - /home
+ borg_excluded_dirs:
+ - /home/*/.cache
+ borg_systemd_oncalendar: "*-*-* 04:00:00"
+```
+
+This creates:
+
+- Two repositories on the Borg server: `/opt/borg/configs` and `/opt/borg/home-data`
+- Two systemd timers: `borg_backup@configs.timer` and `borg_backup@home-data.timer`
+- A single SSH keypair shared between both repositories
+- A single `authorized_keys` entry with both repositories in `--restrict-to-repository`
+
+## Important Behaviors and Limitations
+
+### Consistent `--append-only` Setting Required
+
+All repositories for a given host must use the same `borg_mode_append_only`
+setting. The role will fail with an error if you attempt to configure
+repositories with conflicting `--append-only` settings for the same host.
+
+This is because the SSH `authorized_keys` entry uses a single `--append-only`
+flag that applies to all repositories accessible via that key.
+
+```yaml
+# This will FAIL - conflicting append-only settings
+roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: configs
+ borg_mode_append_only: false # ERROR: inconsistent
+
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: home-data
+ borg_mode_append_only: true # ERROR: inconsistent
+```
+
+### Single SSH Key per Host\*\*
+
+The role generates one SSH keypair per client host. All repositories for that
+host share the same SSH key for authentication to the Borg server.
+
+### Decryption Keys Storage Format
+
+Decryption keys are stored in the `decryption_keys.yml` file using the format:
+
+```yaml
+hostname_repo_name: |
+ To restore key use borg key import --paper /path/to/repo
+ BORG PAPER KEY v1
+ ...
+```
+
+For hosts with multiple repos, each repository has its own entry:
+
+- `borg-client_configs`
+- `borg-client_home-data`
+
+### Deprovisioning Borg Repositories
+
+This role does not currently handle deprovisioning or removing repositories.
+Deprovisioning must be done manually or via separate playbooks.
+
+### Removing a Repository
+
+To remove a specific repository (applies to both single and multi-instance setups):
+
+1. Stop and disable the systemd timer and service:
+
+ ```bash
+ systemctl stop borg_backup@ARGUMENT.timer
+ systemctl disable borg_backup@ARGUMENT.timer
+ systemctl stop borg_backup@ARGUMENT.service
+ systemctl disable borg_backup@ARGUMENT.service
+ ```
+
+ Replace `ARGUMENT` with the `borg_backup_argument` value for that repository.
+
+2. Remove the backup script:
+
+ ```bash
+ rm /usr/local/bin/run_borg_backup@ARGUMENT
+ ```
+
+3. _(Multi-instance only)_ Edit the base backup script to remove the repository block:
+
+ ```bash
+ # Edit /usr/local/bin/run_borg_backup
+ # Remove the ANSIBLE MANAGED BLOCK for the deleted repository
+ ```
+
+4. _(If other repositories remain)_ Update `authorized_keys` on the Borg server
+ to remove the repository restriction. Edit `/opt/borg/.ssh/authorized_keys`
+ and remove the `--restrict-to-repository /opt/borg/REPONAME` from the
+ appropriate line.
+
+ If this is the last repository for the host, remove the entire line instead.
+
+5. Delete the repository on the Borg server:
+
+ ```bash
+ ssh borg@SERVER "borg delete /opt/borg/REPONAME"
+ # Or simply: ssh borg@SERVER "rm -rf /opt/borg/REPONAME"
+ ```
+
+6. Remove the decryption key entry from your `decryption_keys.yml`.
+
+## Deprovisioning a Complete Host
+
+To remove all backup configuration for a host:
+
+1. Stop and disable all systemd timers and services:
+
+ ```bash
+ systemctl list-units --type=timer --all | grep borg_backup
+ # For each relevant timer:
+ systemctl stop borg_backup@*.timer
+ systemctl disable borg_backup@*.timer
+ ```
+
+2. Remove all backup scripts:
+
+ ```bash
+ rm -f /usr/local/bin/run_borg_backup*
+ ```
+
+3. Remove the SSH key from the Borg server's `authorized_keys`:
+
+ ```bash
+ # On the Borg server, edit /opt/borg/.ssh/authorized_keys
+ # Remove the line containing root@HOSTNAME
+ ```
+
+4. Delete all repositories for the host on the Borg server:
+
+ ```bash
+ ssh borg@SERVER "rm -rf /opt/borg/HOSTNAME"
+ # For multi-instance:
+ ssh borg@SERVER "rm -rf /opt/borg/HOSTNAME_repo1"
+ ssh borg@SERVER "rm -rf /opt/borg/HOSTNAME_repo2"
+ ```
+
+5. Remove all decryption key entries for the host from your
+ `decryption_keys.yml`.
+
+## Dependencies
None.
-License
--------
+## License
MIT
diff --git a/molecule/default/tests/test_decryption_keys.py b/molecule/default/tests/test_decryption_keys.py
index 227baf0..c772477 100644
--- a/molecule/default/tests/test_decryption_keys.py
+++ b/molecule/default/tests/test_decryption_keys.py
@@ -6,27 +6,27 @@ import pytest
def _get_keys_path():
"""Get decryption_keys.yml path from molecule environment"""
- scenario_dir = os.environ.get("MOLECULE_SCENARIO_DIRECTORY")
+ scenario_dir = os.environ.get('MOLECULE_SCENARIO_DIRECTORY')
if scenario_dir:
- return os.path.join(scenario_dir, "decryption_keys.yml")
-
+ return os.path.join(scenario_dir, 'decryption_keys.yml')
+
test_dir = os.path.dirname(os.path.abspath(__file__))
- return os.path.join(test_dir, "..", "decryption_keys.yml")
+ return os.path.join(test_dir, '..', 'decryption_keys.yml')
def test_decryption_keys_file_exists():
"""Test that decryption_keys.yml exists"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
+ pytest.skip('decryption_keys.yml not yet generated')
def test_decryption_keys_file_permissions():
"""Test that decryption_keys.yml has secure permissions"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
+ pytest.skip('decryption_keys.yml not yet generated')
+
file_stat = os.stat(keys_path)
file_mode = stat.S_IMODE(file_stat.st_mode)
assert file_mode == 0o600, (
@@ -38,15 +38,15 @@ def test_decryption_keys_structure_single_repo():
"""Test single-repo hosts have correct key format"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
- with open(keys_path, "r") as f:
+ pytest.skip('decryption_keys.yml not yet generated')
+
+ with open(keys_path, 'r') as f:
content = f.read()
-
- assert "borg-client_borg-client:" in content, (
+
+ assert 'borg-client_borg-client:' in content, (
"Single repo host should have key named 'hostname_repo_name'"
)
- assert "borg-client-2_borg-client-2:" in content, (
+ assert 'borg-client-2_borg-client-2:' in content, (
"Second single repo host should have key named 'hostname_repo_name'"
)
@@ -55,15 +55,15 @@ def test_decryption_keys_structure_multi_repo():
"""Test multi-instance hosts have correct key format"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
- with open(keys_path, "r") as f:
+ pytest.skip('decryption_keys.yml not yet generated')
+
+ with open(keys_path, 'r') as f:
content = f.read()
-
- assert "borg-client-multi_configs:" in content, (
+
+ assert 'borg-client-multi_configs:' in content, (
"Multi-instance host should have key for 'configs' repo"
)
- assert "borg-client-multi_home-data:" in content, (
+ assert 'borg-client-multi_home-data:' in content, (
"Multi-instance host should have key for 'home-data' repo"
)
@@ -72,14 +72,14 @@ def test_decryption_keys_multi_instance_separate_entries():
"""Test multi-instance hosts have separate keys for each repo"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
- with open(keys_path, "r") as f:
+ pytest.skip('decryption_keys.yml not yet generated')
+
+ with open(keys_path, 'r') as f:
content = f.read()
-
- configs_count = content.count("borg-client-multi_configs:")
- home_data_count = content.count("borg-client-multi_home-data:")
-
+
+ configs_count = content.count('borg-client-multi_configs:')
+ home_data_count = content.count('borg-client-multi_home-data:')
+
assert configs_count == 1, (
f"configs key should appear exactly once, found {configs_count}"
)
@@ -92,13 +92,13 @@ def test_decryption_keys_contain_paper_key_format():
"""Test that decryption keys use borg paper key format"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
- with open(keys_path, "r") as f:
+ pytest.skip('decryption_keys.yml not yet generated')
+
+ with open(keys_path, 'r') as f:
content = f.read()
-
- assert "BORG PAPER KEY" in content, (
- "Decryption keys should contain borg paper key format"
+
+ assert 'BORG PAPER KEY' in content, (
+ 'Decryption keys should contain borg paper key format'
)
@@ -106,17 +106,17 @@ def test_decryption_keys_all_hosts_present():
"""Test that all expected hosts have keys"""
keys_path = _get_keys_path()
if not os.path.exists(keys_path):
- pytest.skip("decryption_keys.yml not yet generated")
-
- with open(keys_path, "r") as f:
+ pytest.skip('decryption_keys.yml not yet generated')
+
+ with open(keys_path, 'r') as f:
content = f.read()
-
+
expected_keys = [
- "borg-client_borg-client:",
- "borg-client-2_borg-client-2:",
- "borg-client-multi_configs:",
- "borg-client-multi_home-data:",
+ 'borg-client_borg-client:',
+ 'borg-client-2_borg-client-2:',
+ 'borg-client-multi_configs:',
+ 'borg-client-multi_home-data:',
]
-
+
for key in expected_keys:
assert key in content, f"Expected key {key} not found in decryption_keys.yml"
diff --git a/tasks/client_setup.yml b/tasks/client_setup.yml
index 1eae105..8769852 100644
--- a/tasks/client_setup.yml
+++ b/tasks/client_setup.yml
@@ -26,15 +26,67 @@
become: true
register: ssh_key
-- name: Deploy Keys to Borg server
+- name: Ensure authorized_keys file exists on borg server
+ ansible.builtin.file:
+ path: "{{ borg_server_user_home }}/.ssh/authorized_keys"
+ state: touch
+ owner: borg
+ group: borg
+ mode: "0600"
+ access_time: preserve
+ modification_time: preserve
+ become: true
+ delegate_to: "{{ borg_server_host }}"
+
+- name: Read existing authorized_keys content
+ ansible.builtin.slurp:
+ src: "{{ borg_server_user_home }}/.ssh/authorized_keys"
+ become: true
+ delegate_to: "{{ borg_server_host }}"
+ register: auth_keys_content
+
+- name: Find existing authorized_keys line for this host
+ ansible.builtin.set_fact:
+ existing_line: >-
+ {{
+ (auth_keys_content.content | b64decode).splitlines()
+ | select("search", ssh_key.public_key | trim | regex_escape)
+ | first
+ | default("")
+ }}
+
+- name: Detect --append-only setting from existing entry
+ ansible.builtin.set_fact:
+ existing_append_only: "{{ existing_line is search('--append-only') }}"
+ when: existing_line | length > 0
+
+- name: Fail if --append-only setting differs from existing entry
+ ansible.builtin.fail:
+ msg: |
+ Inconsistent --append-only setting for host {{ inventory_hostname }}.
+ Existing entry has --append-only={{ existing_append_only }}, but current invocation uses --append-only={{ borg_mode_append_only }}.
+ All repositories for a host must have the same --append-only setting.
+ when:
+ - existing_line | length > 0
+ - existing_append_only != borg_mode_append_only
+
+- name: Compute all repos for this host
+ ansible.builtin.set_fact:
+ all_repos: >-
+ {{
+ (
+ (existing_line | regex_findall('--restrict-to-repository ([^\s"]+)'))
+ + [borg_server_user_home + '/' + borg_repo_name]
+ ) | unique | sort }}
+
+- name: Update authorized_keys entry for this host
ansible.builtin.lineinfile:
path: "{{ borg_server_user_home }}/.ssh/authorized_keys"
- line: >
- restrict,command="borg serve
- {{ "--append-only" if borg_mode_append_only }}
- --restrict-to-repository {{ borg_repo_name }}"
- {{ ssh_key.public_key }} root@{{ inventory_hostname }}
- search_string: --restrict-to-repository {{ borg_repo_name }}" {{ ssh_key.public_key }}
+ search_string: "{{ ssh_key.public_key | trim }}"
+ line: >-
+ restrict,command="borg serve{{ " --append-only" if borg_mode_append_only }}
+ {{ all_repos | map('regex_replace', '^', '--restrict-to-repository ') | join(' ') }}"
+ {{ ssh_key.public_key | trim }} root@{{ inventory_hostname }}
state: present
become: true
delegate_to: "{{ borg_server_host }}"
@@ -68,7 +120,7 @@
register: local
- name: Add repository encryption keys to ansible repo
- when: not inventory_hostname in local.ansible_facts
+ when: not (inventory_hostname ~ '_' ~ borg_repo_name) in local.ansible_facts
throttle: 1
block:
- name: If host new read encryption keys
@@ -81,7 +133,7 @@
- name: If host new add encryption keys to vars
ansible.builtin.set_fact:
- decryption_keys: "{{ local.ansible_facts | combine({inventory_hostname: borg_keys.stdout}) }}"
+ decryption_keys: "{{ local.ansible_facts | combine({(inventory_hostname ~ '_' ~ borg_repo_name): borg_keys.stdout}) }}"
- name: Update encryption vars
ansible.builtin.copy:
diff --git a/tasks/server_setup.yml b/tasks/server_setup.yml
index 603dd3d..e47d012 100644
--- a/tasks/server_setup.yml
+++ b/tasks/server_setup.yml
@@ -8,15 +8,3 @@
generate_ssh_key: true
become: true
delegate_to: "{{ borg_server_host }}"
-
-- name: Make sure authorized keys exists
- ansible.builtin.file:
- path: "{{ borg_server_user_home }}/.ssh/authorized_keys"
- state: touch
- owner: borg
- group: borg
- mode: "644"
- access_time: preserve
- modification_time: preserve
- become: true
- delegate_to: "{{ borg_server_host }}"