aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md112
-rw-r--r--README.md78
-rw-r--r--defaults/main.yml12
-rw-r--r--meta/argument_specs.yml5
-rw-r--r--molecule/default/converge.yml2
-rw-r--r--molecule/default/tests/test_server_setup.py33
-rw-r--r--tasks/client_setup.yml22
7 files changed, 252 insertions, 12 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..653cbcb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,112 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+<!-- TODO: High level overview of new major release -->
+
+### Breaking Changes
+
+#### Decryption keys file format changed
+
+**Am I affected?**
+You have a `decryption_keys.yml` file with entries from previous versions.
+
+**What changed?**
+Keys now include the repository name to support multiple repos per host.
+
+Old format:
+
+```yaml
+my-host: BORG_KEY_abc123...
+```
+
+New format:
+
+```yaml
+my-host_repo-name: BORG_KEY_abc123...
+```
+
+Generally a harmless change, just leads to duplicate keys with old and new
+format.
+
+**Migration:**
+
+1. Run the role with the new version (new keys created automatically)
+2. Verify backups work correctly
+3. Remove old hostname-only entries from `decryption_keys.yml`
+
+---
+
+#### Backup script block markers changed
+
+**Am I affected?**
+Yes.
+
+**What changed?**
+Block markers in the backup script now include repository name to support
+multiple repos per host.
+
+Old:
+
+```bash
+## BEGIN ANSIBLE MANAGED BLOCK for server: backup-server
+```
+
+New:
+
+```bash
+## BEGIN ANSIBLE MANAGED BLOCK for backup-server/my-repo
+```
+
+**Migration:**
+
+<!-- TODO: We want to auto-migrate this -->
+
+Delete the script and re-run the role:
+
+```bash
+rm /usr/local/bin/run_borg_backup
+# Then run your playbook
+```
+
+---
+
+#### Default backup argument
+
+**Am I affected?**
+You are using the default value of `borg_backup_argument`.
+
+**What will change?**
+Default will change from `{{ borg_server_host_url }}` to
+`{{ borg_server_host_url }}-{{ borg_repo_name }}`.
+
+**Migration:**
+
+<!-- TODO: Consider if we want to migrate this automatically aswell -->
+
+Systemd unit names will change. Manually migrate:
+
+```bash
+# Stop old units
+systemctl stop borg_backup@OLD-VALUE.timer
+systemctl disable borg_backup@OLD-VALUE.timer
+
+# Run role to create new units
+# Then enable new units
+systemctl enable borg_backup@NEW-VALUE.timer
+systemctl start borg_backup@NEW-VALUE.timer
+```
+
+### Added
+
+- Multi-instance backup support (multiple repositories per client host)
+- Non-root backup user support via `borg_client_user` variable
+- Configurable SSH key type (`borg_ssh_key_type`) with support for
+ ed25519, rsa, and ecdsa
+- Per-repo SSH key support (`borg_ssh_key_per_repo`) for independent keys per repository
+- Storage quota support (`borg_storage_quota`) to limit repository size on server
+- Comprehensive test suite including disaster recovery scenarios
+- Negative security tests for cross-host repository isolation
+- Appendix-only repository mode (`borg_mode_append_only`)
diff --git a/README.md b/README.md
index 4235294..3d80e28 100644
--- a/README.md
+++ b/README.md
@@ -115,10 +115,15 @@ variable. You can use placeholders such as `{hostname}` for the backups, see:
This can increase security but comes at the cost of not being able to clean up
old backups from the client.
+`borg_storage_quota` limits the storage space used by the repository on the
+Borg server. Format: `N` (bytes), `NK` (kilobytes), `NM` (megabytes), `NG`
+(gigabytes), `NT` (terabytes). Empty string means no quota.
+
```yaml
borg_repo_name: "{{ inventory_hostname }}"
borg_backup_name_format: "{hostname}-{now:%Y-%m-%dT%H:%M:%S}"
-borg_mode_append_only: false # Server side append only config
+borg_mode_append_only: false
+borg_storage_quota: "" # e.g., "100G" for 100 gigabytes
```
We use zstd compression by default, but you can change it to any of the
@@ -286,14 +291,15 @@ This creates:
## Important Behaviors and Limitations
-### Consistent `--append-only` Setting Required
+### Consistent `--append-only` and `--storage-quota` Settings 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.
+All repositories for a given host must use the same `borg_mode_append_only` and
+`borg_storage_quota` settings when sharing a single SSH key. The role will fail
+with an error if you attempt to configure repositories with conflicting 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.
+This is because the SSH `authorized_keys` entry uses a single `--append-only` and
+`--storage-quota` flag that applies to all repositories accessible via that key.
```yaml
# This will FAIL - conflicting append-only settings
@@ -309,10 +315,62 @@ roles:
borg_mode_append_only: true # ERROR: inconsistent
```
-### Single SSH Key per Host\*\*
+```yaml
+# This will FAIL - conflicting storage quota settings
+roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: configs
+ borg_storage_quota: 10G # ERROR: inconsistent
+
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: home-data
+ borg_storage_quota: 50G # ERROR: inconsistent
+```
+
+### Per-Repo SSH Keys for Independent Settings
+
+To use different `--append-only` or `--storage-quota` settings per repository,
+enable `borg_ssh_key_per_repo: true`. This generates a unique SSH keypair for
+each `(server, repo)` combination, allowing each repository to have its own
+`authorized_keys` entry with independent settings.
+
+```yaml
+- name: Configure repos with independent settings using per-repo SSH keys
+ hosts: borg-client
+ vars:
+ borg_server_host: borg-server
+ borg_server_host_ssh_key: ssh-rsa AAAAAAAA...
+ borg_ssh_key_per_repo: true
+
+ roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: configs
+ borg_backup_argument: configs
+ borg_mode_append_only: true
+ borg_storage_quota: 10G
+ borg_included_dirs:
+ - /etc
+
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: home-data
+ borg_backup_argument: home-data
+ borg_mode_append_only: false # OK: different key
+ borg_storage_quota: 50G # OK: different key
+ borg_included_dirs:
+ - /home
+```
+
+This creates:
-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.
+- Two separate SSH keypairs: `id_ed25519_borgbackup_borg_server_configs` and
+ `id_ed25519_borgbackup_borg_server_home_data`
+- Two separate `authorized_keys` entries with independent restrictions so that
+ each repository can have its own `--append-only` and `--storage-quota`
+ settings
### Decryption Keys Storage Format
diff --git a/defaults/main.yml b/defaults/main.yml
index e3af85c..9869589 100644
--- a/defaults/main.yml
+++ b/defaults/main.yml
@@ -75,11 +75,21 @@ borg_repo_name: "{{ inventory_hostname }}"
# This will deny any request to delete data from the backup repository coming
# from the client host. This is so that an attacker would not be able to simply
# delete the backups from a compromised client.
-# With this configuration option enabled you won't have the ability to remove
+# With this configuration option enabled you won\'t have the ability to remove
# old backups directly from the client that pushes the backups.
# See https://borgbackup.readthedocs.io/en/stable/usage/notes.html#append-only-mode-forbid-compaction
borg_mode_append_only: false
+# Storage quota for the repository (--storage-quota)
+# Limits the storage space used by this repository on the borg server.
+# Format: N (bytes), NK (kilobytes), NM (megabytes), NG (gigabytes), NT (terabytes)
+# Example: "100G" for 100 gigabytes
+# Empty string means no quota (default).
+# Note: When NOT using borg_ssh_key_per_repo, all repos for a host must have
+# the same quota setting, or the role will fail.
+# See https://borgbackup.readthedocs.io/en/stable/usage/serve.html
+borg_storage_quota: ""
+
################################################################################
# Borg Backup Configuration
# See: https://borgbackup.readthedocs.io/en/stable/usage/create.html
diff --git a/meta/argument_specs.yml b/meta/argument_specs.yml
index 2331794..0101ed1 100644
--- a/meta/argument_specs.yml
+++ b/meta/argument_specs.yml
@@ -42,6 +42,11 @@ argument_specs:
required: false
default: false
+ borg_storage_quota:
+ type: str
+ required: false
+ default: ""
+
borg_compression:
type: str
required: false
diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml
index fec5f95..be65609 100644
--- a/molecule/default/converge.yml
+++ b/molecule/default/converge.yml
@@ -146,6 +146,7 @@
borg_ssh_key_per_repo: true
borg_ssh_key_type: ed25519
borg_mode_append_only: true
+ borg_storage_quota: 10G
borg_compression: zstd
borg_included_dirs:
- /etc
@@ -159,6 +160,7 @@
borg_ssh_key_per_repo: true
borg_ssh_key_type: ed25519
borg_mode_append_only: false
+ borg_storage_quota: 50G
borg_compression: lz4
borg_included_dirs:
- /home
diff --git a/molecule/default/tests/test_server_setup.py b/molecule/default/tests/test_server_setup.py
index 5ec1520..0a987b2 100644
--- a/molecule/default/tests/test_server_setup.py
+++ b/molecule/default/tests/test_server_setup.py
@@ -120,6 +120,39 @@ class TestBorgSSHSetup:
'borg-client-multi should NOT have access to borg-client-2 repo'
)
+ def test_authorized_keys_has_storage_quota(self, host):
+ """Verify storage quota is set in authorized_keys for configured repos"""
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+
+ quotas = re.findall(r'--storage-quota (\S+)', content)
+ assert '10G' in quotas, '10G quota should be set for configs-keys repo'
+ assert '50G' in quotas, '50G quota should be set for home-data-keys repo'
+
+ def test_authorized_keys_multi_keys_different_quotas(self, host):
+ """Verify per-repo keys can have different storage quotas"""
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+
+ multi_keys_lines = [
+ line for line in content.split('\n')
+ if line and 'root@borg-client-multi-keys' in line
+ ]
+
+ assert len(multi_keys_lines) == 2, (
+ f"Should have two entries for borg-client-multi-keys, found {len(multi_keys_lines)}"
+ )
+
+ configs_line = [l for l in multi_keys_lines if 'configs-keys' in l][0]
+ home_data_line = [l for l in multi_keys_lines if 'home-data-keys' in l][0]
+
+ assert '--storage-quota 10G' in configs_line, (
+ 'configs-keys should have 10G quota'
+ )
+ assert '--storage-quota 50G' in home_data_line, (
+ 'home-data-keys should have 50G quota'
+ )
+
class TestBorgRepository:
def test_repo_directory_exists(self, host):
diff --git a/tasks/client_setup.yml b/tasks/client_setup.yml
index 18d5b18..e39826a 100644
--- a/tasks/client_setup.yml
+++ b/tasks/client_setup.yml
@@ -107,6 +107,16 @@
existing_append_only: "{{ existing_line is search('--append-only') }}"
when: existing_line | length > 0
+- name: Detect --storage-quota setting from existing entry
+ ansible.builtin.set_fact:
+ existing_storage_quota: >-
+ {{
+ (existing_line | regex_findall('--storage-quota[= ](\d+[KMGT]?)')
+ | first
+ | default(""))
+ }}
+ when: existing_line | length > 0
+
- name: Fail if --append-only setting differs from existing entry
ansible.builtin.fail:
msg: |
@@ -117,6 +127,16 @@
- existing_line | length > 0
- existing_append_only != borg_mode_append_only
+- name: Fail if --storage-quota setting differs from existing entry
+ ansible.builtin.fail:
+ msg: |
+ Inconsistent --storage-quota setting for host {{ inventory_hostname }}.
+ Existing entry has --storage-quota={{ existing_storage_quota }}, but current invocation uses --storage-quota={{ borg_storage_quota }}.
+ All repositories for a host must have the same --storage-quota setting.
+ when:
+ - existing_line | length > 0
+ - existing_storage_quota != (borg_storage_quota | default(""))
+
- name: Compute all repos for this host
ansible.builtin.set_fact:
all_repos: >-
@@ -131,7 +151,7 @@
path: "{{ borg_server_user_home }}/.ssh/authorized_keys"
search_string: "{{ ssh_key.public_key | trim }}"
line: >-
- restrict,command="borg serve{{ " --append-only" if borg_mode_append_only }}
+ restrict,command="borg serve{{ " --append-only" if borg_mode_append_only }}{{ " --storage-quota " ~ borg_storage_quota if borg_storage_quota }}
{{ all_repos | map('regex_replace', '^', '--restrict-to-repository ') | join(' ') }}"
{{ ssh_key.public_key | trim }} {{ borg_client_user }}@{{ inventory_hostname }}
state: present