diff options
| author | Colin Wilk <colin@wilk.cx> | 2026-06-27 21:25:37 +0200 |
|---|---|---|
| committer | Colin Wilk <colin@wilk.cx> | 2026-06-27 21:25:37 +0200 |
| commit | cce7d2d258292c283d64ce8da14a6d1e366b564d (patch) | |
| tree | fe9de437636098066c6bf160d5787b6b35659835 | |
| parent | a22ff185f9836023817f9d4f8df3157b948f8cf2 (diff) | |
| download | ansible-role-borgbackup-cce7d2d258292c283d64ce8da14a6d1e366b564d.tar.gz ansible-role-borgbackup-cce7d2d258292c283d64ce8da14a6d1e366b564d.zip | |
Add support for non-root backup clients
| -rw-r--r-- | README.md | 18 | ||||
| -rw-r--r-- | defaults/main.yml | 19 | ||||
| -rw-r--r-- | molecule/default/converge.yml | 62 | ||||
| -rw-r--r-- | molecule/default/molecule.yml | 8 | ||||
| -rw-r--r-- | molecule/default/tests/test_client_setup.py | 103 | ||||
| -rw-r--r-- | molecule/default/tests/test_server_setup.py | 2 | ||||
| -rw-r--r-- | molecule/default/tests/test_systemd.py | 57 | ||||
| -rw-r--r-- | tasks/client_create_scripts_each.yml | 4 | ||||
| -rw-r--r-- | tasks/client_setup.yml | 57 | ||||
| -rw-r--r-- | templates/borg_backup.service.j2 | 4 |
10 files changed, 270 insertions, 64 deletions
@@ -53,7 +53,23 @@ ansible-galaxy role install git+https://github.com/kliwniloc/ansible-role-borgba ## Role Variables -For more details, see: [`defaults/main.yml`](defaults/main.yml) +Some configuration options were left out of this high level overview. For the +full set of configuration options, see: [`defaults/main.yml`](defaults/main.yml) + +### Backup User Configuration + +By default, the role runs all client-side operations as `root`. To use a +non-root user, set `borg_client_user`: + +```yaml +borg_client_user: backup +``` + +The user must exist before running the role; it will not be created +automatically. Ensure the user can read all directories specified in +`borg_included_dirs`, or backups will fail at runtime. + +### Server Configuration You need to specify your Borg hostname (as defined in your Ansible inventory) as well as the public SSH key from that Borg SSH server. diff --git a/defaults/main.yml b/defaults/main.yml index 337ca76..b3d901c 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,5 +1,24 @@ --- ################################################################################ +# Borg Client User Configuration +################################################################################ + +# User that runs all client-side operations (SSH key, borg init, backups). +# Defaults to root. Set to a non-root user for unprivileged backups. +# The user must exist before running the role. +borg_client_user: root + +# Home directory of the borg_client_user. +# If not set, the role auto-detects it from the passwd database. +# Override this only if you need a non-standard home directory location. +# --- +# borg_client_user_home: /home/backup + +# SSH key type for borg client authentication. +# Options: ed25519 (recommended), rsa, ecdsa +borg_ssh_key_type: rsa + +################################################################################ # Borg Server Host Configuration ################################################################################ diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index c77202c..d90f33a 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -4,6 +4,7 @@ - borg-client - borg-client-2 - borg-client-multi + - borg-client-nonroot vars: borg_server_host: borg-server @@ -12,29 +13,35 @@ # This would usually be set by the user globally on their ansible # repository and can be a security risk to do automatically. We will # however set the variable here in the pre_tasks since it is for testing. - - name: Set borg server openssh key variable + - name: Start ssh + ansible.builtin.systemd: + name: sshd + state: started become: true - block: - - name: Start ssh - ansible.builtin.systemd: - name: sshd - state: started - become: true - delegate_to: "{{ borg_server_host }}" + delegate_to: "{{ borg_server_host }}" - - name: Fetch ssh_key - ansible.builtin.command: >- - ssh-keyscan -t rsa {{ borg_server_host }} | sed "s/^[^ ]* //" - register: borg_server_ssh_keyscan - changed_when: false + - name: Fetch ssh_key + ansible.builtin.command: >- + ssh-keyscan -t rsa {{ borg_server_host }} | sed "s/^[^ ]* //" + register: borg_server_ssh_keyscan + changed_when: false - - name: Set ssh_key - ansible.builtin.set_fact: - borg_server_host_ssh_key: >- - {{ borg_server_ssh_keyscan.stdout - | split(" ") - | reject("search", borg_server_host) - | join(" ") }} + - name: Set ssh_key + ansible.builtin.set_fact: + borg_server_host_ssh_key: >- + {{ borg_server_ssh_keyscan.stdout + | split(" ") + | reject("search", borg_server_host) + | join(" ") }} + + - name: Create backup user for non-root test + ansible.builtin.user: + name: backupuser + home: /home/backupuser + shell: /bin/bash + state: present + become: true + when: inventory_hostname == 'borg-client-nonroot' - name: Converge - Default borg-client hosts: borg-client @@ -62,6 +69,7 @@ borg_server_host: borg-server borg_server_user_home: /opt/borg borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml" + borg_ssh_key_type: ed25519 borg_backup_service_successful_exit_status: - 1 - TEMPFAIL @@ -105,3 +113,17 @@ borg_excluded_dirs: - /home/*/.cache borg_systemd_oncalendar: "*-*-* 04:00:00" + +- name: Converge - Non-root backup user + hosts: borg-client-nonroot + + roles: + - role: kliwniloc.borgbackup + vars: + borg_server_host: borg-server + borg_server_user_home: /opt/borg + borg_client_user: backupuser + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml" + borg_included_dirs: + - /etc + borg_excluded_dirs: [] diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index 0d25587..b4f68cb 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -35,6 +35,14 @@ platforms: networks: - name: molecule-container-net + - name: borg-client-nonroot + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-container-net + - name: borg-server image: ${MOLECULE_DISTRO_SERVER:-debian:12} dockerfile: Dockerfile.j2 diff --git a/molecule/default/tests/test_client_setup.py b/molecule/default/tests/test_client_setup.py index 25bf8e2..290e3dc 100644 --- a/molecule/default/tests/test_client_setup.py +++ b/molecule/default/tests/test_client_setup.py @@ -1,33 +1,72 @@ """Tests for client setup configuration""" import pytest -testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi'] +testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi', 'borg-client-nonroot'] + + +CLIENT_USER_MAP = { + 'borg-client': 'root', + 'borg-client-2': 'root', + 'borg-client-multi': 'root', + 'borg-client-nonroot': 'backupuser', +} + +CLIENT_SSH_KEY_TYPE_MAP = { + 'borg-client': 'rsa', + 'borg-client-2': 'ed25519', + 'borg-client-multi': 'rsa', + 'borg-client-nonroot': 'rsa', +} + + +def get_client_user(host): + hostname = host.backend.get_hostname() + return CLIENT_USER_MAP.get(hostname, 'root') + + +def get_client_ssh_key_type(host): + hostname = host.backend.get_hostname() + return CLIENT_SSH_KEY_TYPE_MAP.get(hostname, 'rsa') + + +def get_client_home(host): + user = get_client_user(host) + return f'/home/{user}' if user != 'root' else '/root' class TestSSHSetup: def test_ssh_directory_exists(self, host): - ssh_dir = host.file('/root/.ssh') + client_home = get_client_home(host) + client_user = get_client_user(host) + ssh_dir = host.file(f'{client_home}/.ssh') assert ssh_dir.exists assert ssh_dir.is_directory - assert ssh_dir.user == 'root' - assert ssh_dir.group == 'root' - assert ssh_dir.mode == 0o640 + assert ssh_dir.user == client_user + assert ssh_dir.group == client_user + assert ssh_dir.mode == 0o700 def test_ssh_private_key_exists(self, host): - key = host.file('/root/.ssh/id_rsa') + client_home = get_client_home(host) + client_user = get_client_user(host) + key_type = get_client_ssh_key_type(host) + key = host.file(f'{client_home}/.ssh/id_{key_type}') assert key.exists - assert key.user == 'root' - assert key.group == 'root' + assert key.user == client_user + assert key.group == client_user assert key.mode == 0o600 def test_ssh_public_key_exists(self, host): - key = host.file('/root/.ssh/id_rsa.pub') + client_home = get_client_home(host) + client_user = get_client_user(host) + key_type = get_client_ssh_key_type(host) + key = host.file(f'{client_home}/.ssh/id_{key_type}.pub') assert key.exists - assert key.user == 'root' - assert key.group == 'root' + assert key.user == client_user + assert key.group == client_user def test_known_hosts_contains_borg_server(self, host): - known_hosts = host.file('/root/.ssh/known_hosts') + client_home = get_client_home(host) + known_hosts = host.file(f'{client_home}/.ssh/known_hosts') assert known_hosts.exists assert known_hosts.contains('borg-server') @@ -35,23 +74,30 @@ class TestSSHSetup: class TestBackupScript: def test_backup_script_exists(self, host): hostname = host.backend.get_hostname() + client_user = get_client_user(host) if hostname == 'borg-client-multi': script1 = host.file('/usr/local/bin/run_borg_backup@configs') script2 = host.file('/usr/local/bin/run_borg_backup@home-data') assert script1.exists - assert script1.user == 'root' - assert script1.group == 'root' + assert script1.user == client_user + assert script1.group == client_user assert script1.mode == 0o711 assert script2.exists - assert script2.user == 'root' - assert script2.group == 'root' + assert script2.user == client_user + assert script2.group == client_user assert script2.mode == 0o711 + elif hostname == 'borg-client-nonroot': + script = host.file('/usr/local/bin/run_borg_backup@borg-server') + assert script.exists + assert script.user == client_user + assert script.group == client_user + assert script.mode == 0o711 elif hostname in ('borg-client', 'borg-client-2'): script = host.file('/usr/local/bin/run_borg_backup') assert script.exists - assert script.user == 'root' - assert script.group == 'root' + assert script.user == client_user + assert script.group == client_user assert script.mode == 0o711 else: pytest.fail(f"Unexpected hostname: {hostname}") @@ -64,6 +110,9 @@ class TestBackupScript: script2 = host.file('/usr/local/bin/run_borg_backup@home-data') assert script1.contains('borg create') assert script2.contains('borg create') + elif hostname == 'borg-client-nonroot': + script = host.file('/usr/local/bin/run_borg_backup@borg-server') + assert script.contains('borg create') elif hostname in ('borg-client', 'borg-client-2'): script = host.file('/usr/local/bin/run_borg_backup') assert script.contains('borg create') @@ -82,8 +131,8 @@ class TestBackupScript: script = host.file('/usr/local/bin/run_borg_backup') assert script.contains('-C') assert script.contains('lz4') - elif hostname == 'borg-client': - script = host.file('/usr/local/bin/run_borg_backup') + elif hostname in ('borg-client', 'borg-client-nonroot'): + script = host.file('/usr/local/bin/run_borg_backup') if hostname == 'borg-client' else host.file('/usr/local/bin/run_borg_backup@borg-server') assert script.contains('-C') assert script.contains('zstd') else: @@ -99,6 +148,10 @@ class TestBackupScript: assert script1.contains('/opt/borg/configs') assert script2.contains('borg@borg-server') assert script2.contains('/opt/borg/home-data') + elif hostname == 'borg-client-nonroot': + script = host.file('/usr/local/bin/run_borg_backup@borg-server') + assert script.contains('borg@borg-server') + assert script.contains('/opt/borg') elif hostname in ('borg-client', 'borg-client-2'): script = host.file('/usr/local/bin/run_borg_backup') assert script.contains('borg@borg-server') @@ -115,6 +168,10 @@ class TestBackupScript: assert '/etc' in script1.content_string assert '/home' in script2.content_string assert '--exclude' in script2.content_string + elif hostname == 'borg-client-nonroot': + script = host.file('/usr/local/bin/run_borg_backup@borg-server') + content = script.content_string + assert '/etc' in content elif hostname in ('borg-client', 'borg-client-2'): script = host.file('/usr/local/bin/run_borg_backup') content = script.content_string @@ -130,6 +187,9 @@ class TestBackupScript: script2 = host.file('/usr/local/bin/run_borg_backup@home-data') assert script1.mode == 0o711 assert script2.mode == 0o711 + elif hostname == 'borg-client-nonroot': + script = host.file('/usr/local/bin/run_borg_backup@borg-server') + assert script.mode == 0o711 elif hostname in ('borg-client', 'borg-client-2'): script = host.file('/usr/local/bin/run_borg_backup') assert script.mode == 0o711 @@ -143,9 +203,10 @@ class TestMultiInstanceBaseScript: if hostname != 'borg-client-multi': return + client_user = get_client_user(host) base_script = host.file('/usr/local/bin/run_borg_backup') assert base_script.exists - assert base_script.user == 'root' + assert base_script.user == client_user assert base_script.mode == 0o711 def test_base_script_contains_both_blocks(self, host): diff --git a/molecule/default/tests/test_server_setup.py b/molecule/default/tests/test_server_setup.py index cb644cb..3c494f7 100644 --- a/molecule/default/tests/test_server_setup.py +++ b/molecule/default/tests/test_server_setup.py @@ -75,7 +75,7 @@ class TestBorgSSHSetup: if not line.strip(): continue assert line.startswith('restrict,command="borg serve'), f"Line should start with restrict,command: {line[:50]}" - assert 'root@' in line, f"Line should contain root@ hostname marker: {line[-30:]}" + assert '@' in line and line.rstrip().endswith(('borg-client', 'borg-client-2', 'borg-client-multi', 'borg-client-nonroot')), f"Line should contain user@hostname marker: {line[-40:]}" assert '--restrict-to-repository' in line, f"Line should have repo restriction: {line[:80]}" def test_authorized_keys_multi_instance_no_cross_host_repos(self, host): diff --git a/molecule/default/tests/test_systemd.py b/molecule/default/tests/test_systemd.py index 9421959..7b7b2ab 100644 --- a/molecule/default/tests/test_systemd.py +++ b/molecule/default/tests/test_systemd.py @@ -2,7 +2,20 @@ import pytest -testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi'] +testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi', 'borg-client-nonroot'] + + +CLIENT_USER_MAP = { + 'borg-client': 'root', + 'borg-client-2': 'root', + 'borg-client-multi': 'root', + 'borg-client-nonroot': 'backupuser', +} + + +def get_client_user(host): + hostname = host.backend.get_hostname() + return CLIENT_USER_MAP.get(hostname, 'root') class TestSystemdServiceFile: @@ -18,6 +31,12 @@ class TestSystemdServiceFile: assert service2.exists assert service2.user == 'root' assert service2.group == 'root' + elif hostname == 'borg-client-nonroot': + service = host.file('/etc/systemd/system/borg_backup@borg-server.service') + assert service.exists + assert service.user == 'root' + assert service.group == 'root' + assert service.mode == 0o644 elif hostname in ('borg-client', 'borg-client-2'): service = host.file('/etc/systemd/system/borg_backup@borg-server.service') assert service.exists @@ -38,6 +57,13 @@ class TestSystemdServiceFile: assert service.contains('[Service]') assert service.contains('[Install]') assert service.contains('Type=oneshot') + elif hostname == 'borg-client-nonroot': + service = host.file('/etc/systemd/system/borg_backup@borg-server.service') + assert service.contains('[Unit]') + assert service.contains('[Service]') + assert service.contains('[Install]') + assert service.contains('Type=oneshot') + assert service.contains('ExecStart=/usr/local/bin/run_borg_backup') elif hostname in ('borg-client', 'borg-client-2'): service = host.file('/etc/systemd/system/borg_backup@borg-server.service') assert service.contains('[Unit]') @@ -48,9 +74,26 @@ class TestSystemdServiceFile: else: pytest.fail(f"Unexpected hostname: {hostname}") - def test_success_exit_status(self, host): + def test_service_user(self, host): hostname = host.backend.get_hostname() + client_user = get_client_user(host) + if hostname == 'borg-client-multi': + service1 = host.file('/etc/systemd/system/borg_backup@configs.service') + service2 = host.file('/etc/systemd/system/borg_backup@home-data.service') + for service in [service1, service2]: + assert service.contains(f'User={client_user}') + assert service.contains(f'Group={client_user}') + elif hostname in ('borg-client', 'borg-client-2', 'borg-client-nonroot'): + service = host.file('/etc/systemd/system/borg_backup@borg-server.service') + assert service.contains(f'User={client_user}') + assert service.contains(f'Group={client_user}') + else: + pytest.fail(f"Unexpected hostname: {hostname}") + + def test_success_exit_status(self, host): + hostname = host.backend.get_hostname() + if hostname in ('borg-client-multi', 'borg-client-nonroot'): return service = host.file('/etc/systemd/system/borg_backup@borg-server.service') @@ -80,7 +123,7 @@ class TestSystemdTimerFile: timer2 = host.file('/etc/systemd/system/borg_backup@home-data.timer') assert timer1.exists assert timer2.exists - elif hostname in ('borg-client', 'borg-client-2'): + elif hostname in ('borg-client', 'borg-client-2', 'borg-client-nonroot'): timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer') assert timer.exists assert timer.user == 'root' @@ -101,7 +144,7 @@ class TestSystemdTimerFile: assert timer.contains('[Install]') assert timer.contains('OnCalendar=') assert timer.contains('AccuracySec=') - elif hostname in ('borg-client', 'borg-client-2'): + elif hostname in ('borg-client', 'borg-client-2', 'borg-client-nonroot'): timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer') assert timer.contains('[Unit]') assert timer.contains('[Timer]') @@ -122,7 +165,7 @@ class TestSystemdTimerFile: elif hostname == 'borg-client-2': timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer') assert 'OnCalendar=*-*-* 03:00:00' in timer.content_string - elif hostname == 'borg-client': + elif hostname in ('borg-client', 'borg-client-nonroot'): timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer') assert 'OnCalendar=*-*-* 02:00:00' in timer.content_string else: @@ -140,7 +183,7 @@ class TestSystemdState: assert c1.stdout.strip() == 'enabled' assert c2.rc == 0 assert c2.stdout.strip() == 'enabled' - elif hostname in ('borg-client', 'borg-client-2'): + elif hostname in ('borg-client', 'borg-client-2', 'borg-client-nonroot'): timer_name = 'borg_backup@borg-server.timer' c = host.run(f"systemctl is-enabled {timer_name}") assert c.rc == 0 @@ -156,7 +199,7 @@ class TestSystemdState: c2 = host.run('systemctl is-active borg_backup@home-data.timer') assert c1.rc == 0 assert c2.rc == 0 - elif hostname in ('borg-client', 'borg-client-2'): + elif hostname in ('borg-client', 'borg-client-2', 'borg-client-nonroot'): timer_name = 'borg_backup@borg-server.timer' c = host.run(f"systemctl is-active {timer_name}") assert c.rc == 0 diff --git a/tasks/client_create_scripts_each.yml b/tasks/client_create_scripts_each.yml index b91c385..6c0615f 100644 --- a/tasks/client_create_scripts_each.yml +++ b/tasks/client_create_scripts_each.yml @@ -3,8 +3,8 @@ ansible.builtin.file: dest: "{{ script_location }}" state: touch - owner: root - group: root + owner: "{{ borg_client_user }}" + group: "{{ borg_client_user }}" modification_time: preserve access_time: preserve mode: "0711" diff --git a/tasks/client_setup.yml b/tasks/client_setup.yml index 8769852..a1a2267 100644 --- a/tasks/client_setup.yml +++ b/tasks/client_setup.yml @@ -1,10 +1,43 @@ --- +- name: Ensure borg_client_user exists + ansible.builtin.getent: + database: passwd + key: "{{ borg_client_user }}" + become: true + +- name: Compute borg_client_user_home if not set + ansible.builtin.set_fact: + borg_client_user_home: "{{ getent_passwd[borg_client_user][4] }}" + when: borg_client_user_home is not defined + +- name: Validate borg_client_user home exists + ansible.builtin.stat: + path: "{{ borg_client_user_home }}" + register: user_home_stat + become: true + +- name: Fail if borg_client_user home missing + ansible.builtin.fail: + msg: | + Home directory {{ borg_client_user_home }} for user {{ borg_client_user }} does not exist. + Please ensure the user has a valid home directory before running this role. + when: not user_home_stat.stat.exists + +- name: Check readability of included paths + ansible.builtin.stat: + path: "{{ item }}" + loop: "{{ borg_included_dirs }}" + register: included_paths_stat + become: true + become_user: "{{ borg_client_user }}" + when: borg_included_dirs | length > 0 + - name: Create SSH Directory ansible.builtin.file: - path: /root/.ssh - owner: root - group: root - mode: "0640" + path: "{{ borg_client_user_home }}/.ssh" + owner: "{{ borg_client_user }}" + group: "{{ borg_client_user }}" + mode: "0700" state: directory become: true @@ -12,17 +45,19 @@ ansible.builtin.known_hosts: name: "{{ borg_server_host_url }}" key: "{{ borg_server_host_url }} {{ borg_server_host_ssh_key }}" - path: /root/.ssh/known_hosts + path: "{{ borg_client_user_home }}/.ssh/known_hosts" state: present become: true + become_user: "{{ borg_client_user }}" - name: Generate SSH keys community.crypto.openssh_keypair: - path: /root/.ssh/id_rsa - owner: root - group: root + path: "{{ borg_client_user_home }}/.ssh/id_{{ borg_ssh_key_type }}" + type: "{{ borg_ssh_key_type }}" + owner: "{{ borg_client_user }}" + group: "{{ borg_client_user }}" mode: "0600" - comment: root@{{ inventory_hostname }} + comment: "{{ borg_client_user }}@{{ inventory_hostname }}" become: true register: ssh_key @@ -86,7 +121,7 @@ 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 }} + {{ ssh_key.public_key | trim }} {{ borg_client_user }}@{{ inventory_hostname }} state: present become: true delegate_to: "{{ borg_server_host }}" @@ -98,6 +133,7 @@ environment: BORG_PASSPHRASE: "{{ borg_passphrase }}" become: true + become_user: "{{ borg_client_user }}" register: init_borg_output changed_when: init_borg_output.rc != 2 failed_when: @@ -128,6 +164,7 @@ borg key export --paper borg@{{ borg_server_host_url }}:{{ borg_server_user_home }}/{{ borg_repo_name }} become: true + become_user: "{{ borg_client_user }}" register: borg_keys changed_when: borg_keys.rc != 0 diff --git a/templates/borg_backup.service.j2 b/templates/borg_backup.service.j2 index 5bbd90f..04f2a49 100644 --- a/templates/borg_backup.service.j2 +++ b/templates/borg_backup.service.j2 @@ -5,8 +5,8 @@ Wants={{ borg_backup_timer_name }}{{ "@" if borg_backup_argument != "" }}{{ borg [Service] Type=oneshot ExecStart={{ borg_backup_script_location }} -User=root -Group=root +User={{ borg_client_user }} +Group={{ borg_client_user }} {% if borg_backup_service_successful_exit_status | length > 0 %} SuccessExitStatus={{ borg_backup_service_successful_exit_status | join(' ') }} {% endif %} |