From ccacad3d37f9ceba1171dd607cd2e01fc7e1e45a Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 19:25:05 +0200 Subject: [PATCH] (bugfix) Grant service user backup and journal access Update the native installer so the pobsync service user gets journal read access when the host exposes systemd-journal or adm groups. Apply ownership and private directory modes to the configured backup root, and reuse the existing environment backup root on reinstall so production updates do not fall back to /backups. Add a self-check for journal access and a host detail action that can prepare missing backup directories for existing host configurations. --- README.md | 1 + scripts/install-systemd | 26 +++++++++++- src/pobsync_backend/self_check.py | 20 +++++++++ .../pobsync_backend/host_detail.html | 4 ++ src/pobsync_backend/tests/test_self_check.py | 42 +++++++++++++++++++ src/pobsync_backend/tests/test_views.py | 16 +++++++ src/pobsync_backend/views.py | 13 ++++++ src/pobsync_server/urls.py | 1 + 8 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/pobsync_backend/tests/test_self_check.py diff --git a/README.md b/README.md index 3683a87..1c3eeef 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The UI includes: - manual backup queueing - snapshot discovery - host checks for backup directories and SSH readiness +- host directory preparation for new or existing hosts - SQL retention planning and apply flow - Django-managed SSH keys - `/self-check/` for runtime checks diff --git a/scripts/install-systemd b/scripts/install-systemd index 4fc5a99..422dc8e 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -12,6 +12,10 @@ SERVER_NAME=${POBSYNC_SERVER_NAME:-_} ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1} CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-} BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups} +BACKUP_ROOT_EXPLICIT=0 +if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then + BACKUP_ROOT_EXPLICIT=1 +fi WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010} FORCE_ENV=0 INSTALL_OS_PACKAGES=1 @@ -55,6 +59,7 @@ while [ "$#" -gt 0 ]; do ;; --backup-root) BACKUP_ROOT=$2 + BACKUP_ROOT_EXPLICIT=1 shift 2 ;; --allowed-hosts) @@ -133,6 +138,16 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne 1 ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then + BACKUP_ROOT=$POBSYNC_BACKUP_ROOT + fi +fi + run_step() { label=$1 shift @@ -343,9 +358,18 @@ else note_step "Create service user" "OK" fi +grant_journal_access() { + for group in systemd-journal adm; do + if getent group "$group" >/dev/null 2>&1; then + usermod -a -G "$group" "$SERVICE_USER" + fi + done +} + +run_step "Grant journal access" grant_journal_access run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT" run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT" -run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync +run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT" if [ "$SOURCE_DIR" != "$APP_DIR" ]; then run_step "Sync application files" rsync -a --delete \ diff --git a/src/pobsync_backend/self_check.py b/src/pobsync_backend/self_check.py index 10c5376..d354c57 100644 --- a/src/pobsync_backend/self_check.py +++ b/src/pobsync_backend/self_check.py @@ -206,4 +206,24 @@ def _systemd_checks() -> list[SelfCheck]: active_state, ) ) + if shutil.which("journalctl") is not None: + result = subprocess.run( + ["journalctl", "--no-pager", "-n", "1", "-u", "pobsync-web.service"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + ) + journal_error = result.stderr.strip() + journal_denied = "No journal files were opened" in journal_error or "permission" in journal_error.lower() + has_journal_access = result.returncode == 0 and not journal_denied + checks.append( + SelfCheck( + "Journal access", + "ok" if has_journal_access else "failed", + "pobsync can read service logs." if has_journal_access else "pobsync cannot read service logs.", + journal_error, + ) + ) return checks diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 0d0aabc..43a2d5a 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -13,6 +13,10 @@ Plan retention Edit schedule +
+ {% csrf_token %} + +
diff --git a/src/pobsync_backend/tests/test_self_check.py b/src/pobsync_backend/tests/test_self_check.py new file mode 100644 index 0000000..c985e2e --- /dev/null +++ b/src/pobsync_backend/tests/test_self_check.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import subprocess +from unittest.mock import patch + +from django.test import SimpleTestCase + +from pobsync_backend.self_check import _systemd_checks + + +class SystemdSelfCheckTests(SimpleTestCase): + def test_journal_permission_hint_is_reported_as_failure(self) -> None: + def which(binary: str) -> str | None: + if binary in {"systemctl", "journalctl"}: + return f"/usr/bin/{binary}" + return None + + active_result = subprocess.CompletedProcess( + args=["systemctl"], + returncode=0, + stdout="active\n", + stderr="", + ) + journal_result = subprocess.CompletedProcess( + args=["journalctl"], + returncode=0, + stdout="", + stderr="No journal files were opened due to insufficient permissions.", + ) + + with patch("pobsync_backend.self_check.Path.exists", return_value=True), patch( + "pobsync_backend.self_check.shutil.which", + side_effect=which, + ), patch( + "pobsync_backend.self_check.subprocess.run", + side_effect=[active_result, active_result, active_result, journal_result], + ): + checks = _systemd_checks() + + journal_check = next(check for check in checks if check.name == "Journal access") + self.assertEqual(journal_check.status, "failed") + self.assertEqual(journal_check.message, "pobsync cannot read service logs.") diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index c4a9275..5f8e6aa 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -452,12 +452,28 @@ class ViewTests(TestCase): self.assertContains(response, "Queue dry-run") self.assertContains(response, "Queue backup") self.assertContains(response, "Host Check") + self.assertContains(response, reverse("prepare_host_directories", args=[host.host])) self.assertContains(response, "ready") self.assertContains(response, "Snapshot Discovery") self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) + def test_prepare_host_directories_action_creates_missing_directories(self) -> None: + self.client.force_login(self.staff_user) + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + response = self.client.post(reverse("prepare_host_directories", args=[host.host]), follow=True) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Prepared backup directories") + self.assertTrue((backup_root / host.host / "scheduled").is_dir()) + self.assertTrue((backup_root / host.host / "manual").is_dir()) + self.assertTrue((backup_root / host.host / ".incomplete").is_dir()) + def test_host_detail_surfaces_active_backup_run(self) -> None: self.client.force_login(self.staff_user) GlobalConfig.objects.create(name="default", backup_root="/backups") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index b0b5837..22a6b40 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -217,6 +217,19 @@ def host_detail(request, host: str): return render(request, "pobsync_backend/host_detail.html", context) +@staff_member_required +@require_POST +def prepare_host_directories(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + try: + host_root = ensure_host_directories(host_config) + except Exception as exc: + messages.error(request, f"Could not prepare backup directories for {host_config.host}: {exc}") + else: + messages.success(request, f"Prepared backup directories for {host_config.host}: {host_root}") + return redirect("host_detail", host=host_config.host) + + @staff_member_required @require_POST def queue_manual_backup(request, host: str): diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 20d3ad7..7ea2a71 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path("hosts/new/", views.create_host_config, name="create_host_config"), path("hosts//", views.host_detail, name="host_detail"), path("hosts//config/", views.edit_host_config, name="edit_host_config"), + path("hosts//prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"), path("hosts//queue-backup/", views.queue_manual_backup, name="queue_manual_backup"), path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("hosts//retention-apply/", views.apply_host_retention, name="apply_host_retention"),