From 8b0175c70cbf3ed63c9a0e617c3f1e5332650ff6 Mon Sep 17 00:00:00 2001 From: Colin Wilk Date: Sat, 27 Jun 2026 21:20:35 +0000 Subject: feat: add storage quota support Add borg_storage_quota variable to limit repository storage on the borg server via --storage-quota option in authorized_keys. When not using borg_ssh_key_per_repo, all repos for a host must share the same quota setting (similar to --append-only). Per-repo SSH keys enable independent quotas per repository. --- CHANGELOG.md | 112 ++++++++++++++++++++++++++++ README.md | 78 ++++++++++++++++--- defaults/main.yml | 12 ++- meta/argument_specs.yml | 5 ++ molecule/default/converge.yml | 2 + molecule/default/tests/test_server_setup.py | 33 ++++++++ tasks/client_setup.yml | 22 +++++- 7 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md 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] + + + +### 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:** + + + +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:** + + + +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 -- cgit v1.2.3