Browse Source

fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)

Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
maziggy 1 month ago
parent
commit
5bbfeefa65

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 14 - 0
backend/app/api/routes/local_backup.py

@@ -39,6 +39,20 @@ async def get_status(
     }
 
 
+@router.get("/path-check")
+async def check_path(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Check that the configured output directory can actually be written to.
+
+    Writes and removes a probe file. A path the service cannot write to — a NAS
+    share outside the systemd unit's ReadWritePaths, say — otherwise only shows
+    up as a failed backup hours later (#2544).
+    """
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.check_path(settings["path"])
+
+
 @router.post("/run")
 async def trigger_backup(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),

+ 200 - 0
backend/app/services/backup_path.py

@@ -0,0 +1,200 @@
+"""Why a backup directory is not writable — and what to actually do about it.
+
+Bambuddy's systemd unit runs with ``ProtectSystem=strict``. That mounts the
+entire filesystem read-only inside the service's own mount namespace and carves
+back out only ``ReadWritePaths=<install> <data> <logs>``. A backup output path
+on a NAS mount is therefore read-only *to the service* while the operator's own
+shell writes to it happily. The kernel reports this as ``EROFS``, not
+``EACCES``, so the obvious move — checking folder permissions — turns up nothing
+and the real cause (our own unit file) is the last place anyone looks (#2544).
+
+Docker has the same shape with a different cause: a host path that was never
+bind-mounted into the container is simply not the host path. Worse, it is still
+*writable* — the write lands in the container's ephemeral layer and vanishes on
+the next ``docker compose up``. A backup that silently goes nowhere is the one
+failure mode a backup feature must not have.
+
+So: probe the directory with a real write before trusting it, and when that
+write fails, name which of these it is and hand back the exact command that
+fixes it.
+"""
+
+from __future__ import annotations
+
+import errno
+import logging
+import os
+import re
+import tempfile
+from pathlib import Path
+
+from backend.app.services.discovery import is_running_in_docker
+
+logger = logging.getLogger(__name__)
+
+# Cgroup line for a systemd service, e.g.
+#   0::/system.slice/bambuddy.service
+#   0::/system.slice/system-bambuddy.slice/bambuddy@1.service
+_SERVICE_CGROUP = re.compile(r"/([^/]+\.service)\b")
+
+
+def systemd_unit_name() -> str | None:
+    """Name of the systemd unit we are running as, or None if we are not one.
+
+    ``INVOCATION_ID`` is set by systemd for every unit it starts and by nothing
+    else, so it is the signal that we are a unit at all. The name itself comes
+    from the cgroup path — systemd exports no environment variable for it.
+    """
+    if not os.environ.get("INVOCATION_ID"):
+        return None
+    try:
+        cgroup = Path("/proc/self/cgroup").read_text()
+    except OSError:
+        return "bambuddy.service"
+    match = _SERVICE_CGROUP.search(cgroup)
+    return match.group(1) if match else "bambuddy.service"
+
+
+def _systemd_remedy(unit: str, path: Path) -> str:
+    return (
+        f"sudo systemctl edit {unit}\n"
+        "\n"
+        "Add these two lines to the drop-in, save, then restart:\n"
+        "\n"
+        "[Service]\n"
+        f"ReadWritePaths={path}\n"
+        "\n"
+        f"sudo systemctl restart {unit}"
+    )
+
+
+def _docker_remedy(path: Path) -> str:
+    return f"services:\n  bambuddy:\n    volumes:\n      - {path}:{path}"
+
+
+def classify_backup_dir_error(exc: OSError, backup_dir: Path) -> dict:
+    """Map an OSError raised while writing to ``backup_dir`` onto a diagnosis.
+
+    ``message`` is English and goes to the log and the API. The frontend
+    translates from ``code`` and renders ``remedy`` verbatim as a snippet.
+    """
+    detail = str(exc)
+    unit = systemd_unit_name()
+
+    if exc.errno == errno.EROFS:
+        if unit:
+            return {
+                "writable": False,
+                "path": str(backup_dir),
+                "code": "sandboxed",
+                "detail": detail,
+                "remedy": _systemd_remedy(unit, backup_dir),
+                "message": (
+                    f"{backup_dir} is read-only for the Bambuddy service. Its systemd unit runs with "
+                    "ProtectSystem=strict, which makes every path outside the install, data and log "
+                    f"directories read-only — add ReadWritePaths={backup_dir} to a drop-in "
+                    f"(sudo systemctl edit {unit}) and restart. If the path is on a network share, also "
+                    "confirm the share itself is not mounted read-only."
+                ),
+            }
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "read_only",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} is on a read-only filesystem.",
+        }
+
+    if exc.errno in (errno.EACCES, errno.EPERM):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "permission_denied",
+            "detail": detail,
+            "remedy": None,
+            "message": f"Bambuddy is not allowed to write to {backup_dir}. Check the directory's owner and mode.",
+        }
+
+    if exc.errno == errno.ENOSPC:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "no_space",
+            "detail": detail,
+            "remedy": None,
+            "message": f"No space left on the filesystem holding {backup_dir}.",
+        }
+
+    if exc.errno in (errno.ENOTDIR, errno.EEXIST):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "not_a_directory",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} exists but is not a directory.",
+        }
+
+    if exc.errno == errno.ENOENT:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "missing",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} does not exist and could not be created.",
+        }
+
+    return {
+        "writable": False,
+        "path": str(backup_dir),
+        "code": "error",
+        "detail": detail,
+        "remedy": None,
+        "message": f"Bambuddy cannot write to {backup_dir}: {exc}",
+    }
+
+
+def _is_container_ephemeral(backup_dir: Path) -> bool:
+    """True if this path lives in the container's own writable layer.
+
+    A bind mount or named volume always sits on a different device than the
+    container root, so a matching ``st_dev`` means nothing was mounted here and
+    the backups die with the container.
+    """
+    try:
+        return backup_dir.stat().st_dev == Path("/").stat().st_dev
+    except OSError:
+        return False
+
+
+def probe_backup_dir(backup_dir: Path) -> dict:
+    """Create the directory and write a throwaway file in it.
+
+    Returns the same shape as :func:`classify_backup_dir_error`, plus a
+    ``warning`` code for a directory that is writable but not persistent.
+    """
+    try:
+        backup_dir.mkdir(parents=True, exist_ok=True)
+        with tempfile.NamedTemporaryFile(dir=backup_dir, prefix=".bambuddy-write-test-") as probe:
+            probe.write(b"bambuddy")
+            probe.flush()
+    except OSError as e:
+        result = classify_backup_dir_error(e, backup_dir)
+        logger.warning("Backup path check failed: %s", result["message"])
+        return {**result, "warning": None}
+
+    warning = None
+    if is_running_in_docker() and _is_container_ephemeral(backup_dir):
+        warning = "container_ephemeral"
+
+    return {
+        "writable": True,
+        "path": str(backup_dir),
+        "code": "ok",
+        "detail": None,
+        "remedy": _docker_remedy(backup_dir) if warning else None,
+        "message": f"{backup_dir} is writable.",
+        "warning": warning,
+    }

+ 24 - 3
backend/app/services/local_backup.py

@@ -14,6 +14,7 @@ from sqlalchemy import select
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
+from backend.app.services.backup_path import classify_backup_dir_error, probe_backup_dir
 
 # The TZ-env resolution used to live here. It moved to utils/local_time when the
 # smart-plug energy history (#2539) needed the same local day boundary. Re-exported
@@ -169,6 +170,15 @@ class LocalBackupService:
             return Path(path_setting.strip())
         return _default_backup_dir()
 
+    def check_path(self, path_setting: str) -> dict:
+        """Probe the configured output directory with a real write.
+
+        Called when the path is saved and when the backup card is opened, so a
+        directory the service cannot write to is caught there and then instead
+        of at 03:00 for a week (#2544).
+        """
+        return probe_backup_dir(self._resolve_backup_dir(path_setting))
+
     async def run_backup(self, settings: dict | None = None) -> dict:
         """Run a backup now. Returns {success, message, filename}."""
         if self._running:
@@ -180,11 +190,22 @@ class LocalBackupService:
                 settings = await self._load_settings()
 
             backup_dir = self._resolve_backup_dir(settings["path"])
-            backup_dir.mkdir(parents=True, exist_ok=True)
 
-            from backend.app.api.routes.settings import create_backup_zip
+            try:
+                backup_dir.mkdir(parents=True, exist_ok=True)
+
+                from backend.app.api.routes.settings import create_backup_zip
 
-            zip_path, filename = await create_backup_zip(output_path=backup_dir)
+                zip_path, filename = await create_backup_zip(output_path=backup_dir)
+            except OSError as e:
+                # A raw "[Errno 30] Read-only file system" sends people off to check
+                # folder permissions, which is exactly where the answer is not (#2544).
+                diagnosis = classify_backup_dir_error(e, backup_dir)
+                self._last_backup_at = datetime.now(timezone.utc).isoformat()
+                self._last_status = "failed"
+                self._last_message = diagnosis["message"]
+                logger.error("Local backup failed: %s (%s)", diagnosis["message"], diagnosis["detail"])
+                return {"success": False, "message": diagnosis["message"], "diagnosis": diagnosis}
 
             # Prune old backups
             retention = max(1, settings["retention"])

+ 153 - 0
backend/tests/unit/services/test_backup_path.py

@@ -0,0 +1,153 @@
+"""A backup directory the service cannot write to must say so, and say why (#2544).
+
+The reporting bug this guards against: our own systemd unit ships
+``ProtectSystem=strict``, so a NAS share the operator mounted and can write to
+from their shell is read-only *for the service*. The kernel calls that EROFS,
+the UI showed the raw ``[Errno 30] Read-only file system``, and the reporter
+spent a week checking folder permissions — which were fine, because EROFS is not
+a permission error.
+"""
+
+from __future__ import annotations
+
+import errno
+from pathlib import Path
+
+import pytest
+
+from backend.app.services import backup_path
+from backend.app.services.backup_path import (
+    classify_backup_dir_error,
+    probe_backup_dir,
+    systemd_unit_name,
+)
+
+NAS = Path("/mnt/nasbackup")
+
+
+class TestSystemdUnitName:
+    def test_none_when_not_started_by_systemd(self, monkeypatch):
+        monkeypatch.delenv("INVOCATION_ID", raising=False)
+        assert systemd_unit_name() is None
+
+    @pytest.mark.parametrize(
+        ("cgroup", "expected"),
+        [
+            ("0::/system.slice/bambuddy.service\n", "bambuddy.service"),
+            ("0::/system.slice/system-bambuddy.slice/bambuddy@1.service\n", "bambuddy@1.service"),
+            # No .service in the path (a user scope, say) — still name something usable.
+            ("0::/user.slice/user-1000.slice/session-3.scope\n", "bambuddy.service"),
+        ],
+    )
+    def test_reads_the_unit_name_from_the_cgroup(self, monkeypatch, cgroup, expected):
+        monkeypatch.setenv("INVOCATION_ID", "deadbeef")
+        monkeypatch.setattr(Path, "read_text", lambda _self, *a, **k: cgroup)
+
+        assert systemd_unit_name() == expected
+
+    def test_falls_back_to_bambuddy_when_the_cgroup_is_unreadable(self, monkeypatch):
+        monkeypatch.setenv("INVOCATION_ID", "deadbeef")
+
+        def boom(_path):
+            raise OSError("no /proc here")
+
+        monkeypatch.setattr(Path, "read_text", boom)
+        assert systemd_unit_name() == "bambuddy.service"
+
+
+class TestClassifyReadOnly:
+    def test_erofs_under_systemd_blames_the_sandbox_and_hands_over_the_fix(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+
+        result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
+
+        assert result["writable"] is False
+        assert result["code"] == "sandboxed"
+        assert "ProtectSystem=strict" in result["message"]
+        # The remedy has to be copy-pasteable, with their path already in it.
+        assert "systemctl edit bambuddy.service" in result["remedy"]
+        assert "ReadWritePaths=/mnt/nasbackup" in result["remedy"]
+
+    def test_erofs_outside_systemd_does_not_blame_a_unit_that_does_not_exist(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: None)
+
+        result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
+
+        assert result["code"] == "read_only"
+        assert result["remedy"] is None
+        assert "systemd" not in result["message"]
+
+    def test_eacces_is_a_permission_problem_not_a_sandbox_one(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+
+        result = classify_backup_dir_error(OSError(errno.EACCES, "Permission denied"), NAS)
+
+        assert result["code"] == "permission_denied"
+        assert result["remedy"] is None
+
+    @pytest.mark.parametrize(
+        ("errno_value", "expected"),
+        [
+            (errno.ENOSPC, "no_space"),
+            (errno.ENOTDIR, "not_a_directory"),
+            (errno.ENOENT, "missing"),
+            (errno.EIO, "error"),
+        ],
+    )
+    def test_other_errnos_keep_their_own_identity(self, errno_value, expected):
+        result = classify_backup_dir_error(OSError(errno_value, "boom"), NAS)
+        assert result["code"] == expected
+        assert result["writable"] is False
+
+
+class TestProbe:
+    def test_a_writable_directory_is_reported_writable_and_left_clean(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: False)
+        target = tmp_path / "backups"
+
+        result = probe_backup_dir(target)
+
+        assert result["writable"] is True
+        assert result["code"] == "ok"
+        assert result["warning"] is None
+        assert target.is_dir()
+        # The probe file must not survive — it would show up in the backup list.
+        assert list(target.iterdir()) == []
+
+    def test_a_read_only_directory_is_diagnosed_not_just_reported(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+        target = tmp_path / "nasbackup"
+        target.mkdir()
+
+        def refuse(*_args, **_kwargs):
+            raise OSError(errno.EROFS, "Read-only file system")
+
+        monkeypatch.setattr(backup_path.tempfile, "NamedTemporaryFile", refuse)
+
+        result = probe_backup_dir(target)
+
+        assert result["writable"] is False
+        assert result["code"] == "sandboxed"
+        assert str(target) in result["remedy"]
+
+    def test_docker_path_on_the_container_layer_is_writable_but_flagged(self, tmp_path, monkeypatch):
+        """Writable is not the same as persistent: an un-mounted host path inside a
+        container accepts the write and then loses it on the next `up`.
+        """
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
+        monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: True)
+
+        result = probe_backup_dir(tmp_path / "backups")
+
+        assert result["writable"] is True
+        assert result["warning"] == "container_ephemeral"
+        assert "volumes:" in result["remedy"]
+
+    def test_docker_path_on_a_mounted_volume_is_not_flagged(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
+        monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: False)
+
+        result = probe_backup_dir(tmp_path / "backups")
+
+        assert result["writable"] is True
+        assert result["warning"] is None

+ 74 - 0
backend/tests/unit/test_local_backup.py

@@ -327,3 +327,77 @@ class TestGetStatus:
         assert status["last_backup_at"] is None
         assert status["last_status"] is None
         assert status["next_run"] is None
+
+
+class TestRunBackupDiagnosis:
+    """A failed backup has to name its cause, not just quote errno (#2544).
+
+    ``[Errno 30] Read-only file system`` reads like a broken NAS mount. It is
+    usually our own systemd unit: ProtectSystem=strict makes every path outside
+    the install / data / log dirs read-only for the service, while the operator's
+    shell writes to that same NAS share happily.
+    """
+
+    @pytest.mark.asyncio
+    async def test_read_only_output_dir_is_diagnosed_as_the_sandbox(self, tmp_path, monkeypatch):
+        from backend.app.services import backup_path as backup_path_module
+
+        monkeypatch.setattr(backup_path_module, "systemd_unit_name", lambda: "bambuddy.service")
+
+        service = LocalBackupService()
+        settings = {"path": str(tmp_path / "nasbackup"), "retention": 5}
+
+        with patch(
+            "backend.app.api.routes.settings.create_backup_zip",
+            side_effect=OSError(30, "Read-only file system"),
+        ):
+            result = await service.run_backup(settings)
+
+        assert result["success"] is False
+        assert result["diagnosis"]["code"] == "sandboxed"
+        assert "ProtectSystem=strict" in result["message"]
+        assert "ReadWritePaths=" in result["diagnosis"]["remedy"]
+        # And the status the UI polls carries the same explanation, not the errno.
+        assert "ProtectSystem=strict" in service.get_status()["last_message"]
+
+    @pytest.mark.asyncio
+    async def test_a_non_os_failure_still_reports_plainly(self, tmp_path):
+        service = LocalBackupService()
+        settings = {"path": str(tmp_path), "retention": 5}
+
+        with patch(
+            "backend.app.api.routes.settings.create_backup_zip",
+            side_effect=ValueError("database is locked"),
+        ):
+            result = await service.run_backup(settings)
+
+        assert result["success"] is False
+        assert "database is locked" in result["message"]
+        assert "diagnosis" not in result
+
+
+class TestCheckPath:
+    """The path is probed when it is saved, not first exercised at 03:00."""
+
+    def test_writable_path_reports_ok(self, tmp_path):
+        service = LocalBackupService()
+        result = service.check_path(str(tmp_path / "backups"))
+        assert result["writable"] is True
+        assert result["code"] == "ok"
+
+    def test_unwritable_path_reports_the_remedy(self, tmp_path, monkeypatch):
+        from backend.app.services import backup_path as backup_path_module
+
+        monkeypatch.setattr(backup_path_module, "systemd_unit_name", lambda: "bambuddy.service")
+
+        def refuse(*_args, **_kwargs):
+            raise OSError(30, "Read-only file system")
+
+        monkeypatch.setattr(backup_path_module.tempfile, "NamedTemporaryFile", refuse)
+
+        service = LocalBackupService()
+        result = service.check_path(str(tmp_path))
+
+        assert result["writable"] is False
+        assert result["code"] == "sandboxed"
+        assert str(tmp_path) in result["remedy"]

+ 60 - 0
backend/tests/unit/test_systemd_backup_paths.py

@@ -0,0 +1,60 @@
+"""Reinstalling must not silently take away a writable path (#2544).
+
+``ProtectSystem=strict`` means the unit's ``ReadWritePaths`` is the *complete*
+list of places Bambuddy can write. An operator who backs up to a NAS adds their
+share to it by hand — and both installers overwrite the unit file wholesale, so
+that line used to vanish on the next install. The backups then failed with EROFS
+every night, which looks like a NAS permission problem and is not one.
+
+So the installers keep the operator's extra paths, and the unit says why they
+matter.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[3]
+
+INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
+
+
+def _read(rel: str) -> str:
+    path = REPO / rel
+    assert path.is_file(), f"launcher moved or was removed: {rel}"
+    return path.read_text()
+
+
+class TestUnitTemplate:
+    def test_readwritepaths_still_grants_the_three_app_dirs(self):
+        unit = _read("deploy/bambuddy.service")
+        line = next(line for line in unit.splitlines() if line.startswith("ReadWritePaths="))
+        assert "DATA_DIR" in line and "LOG_DIR" in line and "INSTALL_PATH" in line
+
+    def test_unit_explains_how_to_add_a_backup_share(self):
+        """Whoever reads this unit next has to be able to work out why their NAS
+        is read-only for the service but not for their shell.
+        """
+        unit = _read("deploy/bambuddy.service")
+        assert "systemctl edit" in unit, "the unit should show how to add a writable path via a drop-in"
+
+
+class TestInstallersPreserveCustomPaths:
+    @pytest.mark.parametrize("installer", INSTALLERS)
+    def test_generated_unit_appends_the_carried_over_paths(self, installer):
+        script = _read(installer)
+        line = next(line for line in script.splitlines() if line.startswith("ReadWritePaths="))
+        assert "$extra_rw" in line, (
+            f"{installer} writes ReadWritePaths without $extra_rw, so a NAS share the operator "
+            "added to the unit is dropped on reinstall:\n" + line
+        )
+
+    @pytest.mark.parametrize("installer", INSTALLERS)
+    def test_existing_unit_is_read_for_custom_paths_and_backed_up(self, installer):
+        script = _read(installer)
+        assert "ReadWritePaths=" in script and "extra_rw+=" in script, (
+            f"{installer} no longer carries the previous unit's ReadWritePaths forward"
+        )
+        assert ".bak-" in script, f"{installer} overwrites the unit without backing it up first"

+ 13 - 0
deploy/bambuddy.service

@@ -70,6 +70,19 @@ ProtectSystem=strict
 # under /home (issue #1685). Default is the safer read-only; flip to true if
 # your INSTALL_PATH is outside /home (e.g. /opt/bambuddy).
 ProtectHome=read-only
+#
+# ProtectSystem=strict mounts EVERYTHING outside these three paths read-only for
+# this service — including a NAS share you have mounted yourself and can write to
+# from your own shell. Writes there fail with EROFS ("Read-only file system"),
+# which looks like a permission problem but is not one (issue #2544).
+#
+# So if you point Scheduled Backups at a directory outside the install, data and
+# log dirs, add it here — or better, in a drop-in that survives a reinstall:
+#
+#   sudo systemctl edit bambuddy
+#   [Service]
+#   ReadWritePaths=/mnt/your-nas-share
+#
 ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
 
 [Install]

+ 115 - 0
frontend/src/__tests__/components/GitHubBackupSettings.pathCheck.test.tsx

@@ -0,0 +1,115 @@
+/**
+ * The backup card must say when it cannot write to the output directory (#2544).
+ *
+ * The reporter's NAS share was read-only *to the service* (our systemd unit ships
+ * ProtectSystem=strict), his shell wrote to it fine, and the UI only ever said
+ * "Failed". A week of nightly backups went nowhere. The banner below is what
+ * turns that into something actionable.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getGitHubBackupConfig: vi.fn().mockResolvedValue(null),
+    getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: false, enabled: false }),
+    getGitHubBackupLogs: vi.fn().mockResolvedValue([]),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    getLocalBackups: vi.fn().mockResolvedValue([]),
+    getLocalBackupStatus: vi.fn().mockResolvedValue({
+      enabled: true,
+      schedule: 'daily',
+      time: '07:00',
+      retention: 30,
+      path: '/mnt/nasbackup',
+      default_path: '/app/data/backups',
+      is_running: false,
+      last_backup_at: null,
+      last_status: null,
+      last_message: null,
+      next_run: null,
+      timezone: 'America/New_York',
+    }),
+    checkLocalBackupPath: vi.fn(),
+  },
+}));
+
+const SANDBOXED = {
+  writable: false,
+  path: '/mnt/nasbackup',
+  code: 'sandboxed',
+  detail: "[Errno 30] Read-only file system: '/mnt/nasbackup/.bambuddy-write-test-x'",
+  remedy: 'sudo systemctl edit bambuddy.service\n\n[Service]\nReadWritePaths=/mnt/nasbackup',
+  message: '/mnt/nasbackup is read-only for the Bambuddy service.',
+  warning: null,
+};
+
+describe('GitHubBackupSettings — backup path check', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('explains an unwritable path instead of leaving the user with an errno', async () => {
+    vi.mocked(api.checkLocalBackupPath).mockResolvedValue(SANDBOXED);
+
+    render(<GitHubBackupSettings />);
+
+    expect(await screen.findByText(/cannot write to this directory/i)).toBeInTheDocument();
+    // The cause, not just the symptom.
+    expect(screen.getByText(/ProtectSystem=strict/)).toBeInTheDocument();
+    // And the raw OS error is still there for anyone who wants it.
+    expect(screen.getByText(/Errno 30/)).toBeInTheDocument();
+  });
+
+  it('shows the drop-in that fixes it, with the operator\'s own path in it', async () => {
+    vi.mocked(api.checkLocalBackupPath).mockResolvedValue(SANDBOXED);
+
+    render(<GitHubBackupSettings />);
+
+    const remedy = await screen.findByText(/ReadWritePaths=\/mnt\/nasbackup/);
+    expect(remedy).toBeInTheDocument();
+    expect(remedy.textContent).toContain('systemctl edit bambuddy.service');
+  });
+
+  it('warns when the path is writable but lives inside the container', async () => {
+    vi.mocked(api.checkLocalBackupPath).mockResolvedValue({
+      writable: true,
+      path: '/backups',
+      code: 'ok',
+      detail: null,
+      remedy: 'services:\n  bambuddy:\n    volumes:\n      - /backups:/backups',
+      message: '/backups is writable.',
+      warning: 'container_ephemeral',
+    });
+
+    render(<GitHubBackupSettings />);
+
+    expect(await screen.findByText(/will not survive a container restart/i)).toBeInTheDocument();
+  });
+
+  it('stays quiet when the directory is fine', async () => {
+    vi.mocked(api.checkLocalBackupPath).mockResolvedValue({
+      writable: true,
+      path: '/mnt/nasbackup',
+      code: 'ok',
+      detail: null,
+      remedy: null,
+      message: '/mnt/nasbackup is writable.',
+      warning: null,
+    });
+
+    render(<GitHubBackupSettings />);
+
+    await waitFor(() => expect(api.checkLocalBackupPath).toHaveBeenCalled());
+    expect(screen.queryByText(/cannot write to this directory/i)).not.toBeInTheDocument();
+    expect(screen.queryByText(/will not survive a container restart/i)).not.toBeInTheDocument();
+  });
+});

+ 18 - 0
frontend/src/api/client.ts

@@ -2628,6 +2628,21 @@ export interface LocalBackupFile {
   created_at: string;
 }
 
+/** Result of writing a probe file into the configured backup directory (#2544). */
+export interface LocalBackupPathCheck {
+  writable: boolean;
+  path: string;
+  /** 'ok' | 'sandboxed' | 'read_only' | 'permission_denied' | 'no_space' | 'not_a_directory' | 'missing' | 'error' */
+  code: string;
+  /** Raw OS error, shown as-is under the translated explanation. */
+  detail: string | null;
+  /** Copy-pasteable fix (systemd drop-in, compose snippet) — not translated. */
+  remedy: string | null;
+  message: string;
+  /** 'container_ephemeral' — writable, but the backups die with the container. */
+  warning: string | null;
+}
+
 export interface ObicoDetectionEvent {
   printer_id: number;
   task_name: string;
@@ -6349,6 +6364,9 @@ export const api = {
   triggerLocalBackup: () =>
     request<{ success: boolean; message: string; filename?: string }>('/local-backup/run', { method: 'POST' }),
 
+  checkLocalBackupPath: () =>
+    request<LocalBackupPathCheck>('/local-backup/path-check'),
+
   getLocalBackups: () =>
     request<LocalBackupFile[]>('/local-backup/backups'),
 

+ 66 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -29,6 +29,7 @@ import type {
   GitHubBackupTriggerResponse,
   GitProviderType,
   LocalBackupFile,
+  LocalBackupPathCheck,
   LocalBackupStatus,
   ScheduleType,
   CloudAuthStatus,
@@ -173,6 +174,18 @@ export function GitHubBackupSettings() {
     refetchInterval: 30000,
   });
 
+  // Probes the output directory with a real write (#2544). A directory the
+  // service cannot write to — a NAS share outside the systemd unit's
+  // ReadWritePaths, typically — otherwise only surfaces as a failed backup at
+  // 03:00, for however many nights it takes someone to notice. Refetched
+  // explicitly when the path changes or a backup runs, not polled: it writes.
+  const { data: localBackupPathCheck, refetch: refetchLocalPathCheck } = useQuery<LocalBackupPathCheck>({
+    queryKey: ['local-backup-path-check'],
+    queryFn: api.checkLocalBackupPath,
+    enabled: localBackupStatus?.enabled === true,
+    refetchOnWindowFocus: false,
+  });
+
   // Sync local path state from server
   useEffect(() => {
     if (localBackupStatus?.path !== undefined) {
@@ -190,6 +203,7 @@ export function GitHubBackupSettings() {
       }
       refetchLocalStatus();
       refetchLocalBackups();
+      refetchLocalPathCheck();
     },
     onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
   });
@@ -1239,6 +1253,7 @@ export function GitHubBackupSettings() {
                       }
                       refetchLocalStatus();
                       refetchLocalBackups();
+                      refetchLocalPathCheck();
                     }}
                     onKeyDown={(e) => {
                       if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
@@ -1250,6 +1265,57 @@ export function GitHubBackupSettings() {
                       : <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
                     }
                   </p>
+
+                  {/* The directory cannot be written to — say why, and how to fix it (#2544) */}
+                  {localBackupPathCheck && !localBackupPathCheck.writable && (
+                    <div className="mt-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                      <div className="flex items-start gap-2 text-sm">
+                        <AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
+                        <div className="min-w-0 text-red-800 dark:text-red-200">
+                          <p className="font-medium">{t('backup.pathCheck.title')}</p>
+                          <p className="mt-1 text-red-800/80 dark:text-red-200/80">
+                            {t(`backup.pathCheck.${localBackupPathCheck.code}`, {
+                              path: localBackupPathCheck.path,
+                              defaultValue: localBackupPathCheck.message,
+                            })}
+                          </p>
+                          {localBackupPathCheck.remedy && (
+                            <>
+                              <p className="mt-2 font-medium">{t('backup.pathCheck.howToFix')}</p>
+                              <pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
+                                {localBackupPathCheck.remedy}
+                              </pre>
+                            </>
+                          )}
+                          {localBackupPathCheck.detail && (
+                            <p className="mt-2 text-xs text-red-800/60 dark:text-red-200/60 break-words">
+                              {localBackupPathCheck.detail}
+                            </p>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Writable, but only inside the container — the backups die with it */}
+                  {localBackupPathCheck?.writable && localBackupPathCheck.warning === 'container_ephemeral' && (
+                    <div className="mt-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
+                      <div className="flex items-start gap-2 text-sm">
+                        <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
+                        <div className="min-w-0 text-yellow-800 dark:text-yellow-200">
+                          <p className="font-medium">{t('backup.pathCheck.ephemeralTitle')}</p>
+                          <p className="mt-1 text-yellow-800/80 dark:text-yellow-200/80">
+                            {t('backup.pathCheck.container_ephemeral', { path: localBackupPathCheck.path })}
+                          </p>
+                          {localBackupPathCheck.remedy && (
+                            <pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
+                              {localBackupPathCheck.remedy}
+                            </pre>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+                  )}
                 </div>
 
                 {/* Status + Run Now */}

+ 14 - 0
frontend/src/i18n/locales/de.ts

@@ -4797,6 +4797,20 @@ export default {
     backupSize: 'Größe',
     localTimeHint: 'Ortszeit ({{tz}})',
     defaultPathLabel: 'Standard:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy kann nicht in dieses Verzeichnis schreiben',
+      howToFix: 'So beheben Sie das:',
+      sandboxed: 'Der Bambuddy-Dienst kann nicht nach {{path}} schreiben. Seine systemd-Unit läuft mit ProtectSystem=strict, wodurch jedes Verzeichnis außerhalb der Installations-, Daten- und Log-Verzeichnisse für den Dienst schreibgeschützt ist - auch eines, in das Sie aus Ihrer eigenen Shell schreiben können.',
+      read_only: '{{path}} liegt auf einem schreibgeschützten Dateisystem.',
+      permission_denied: 'Bambuddy darf nicht nach {{path}} schreiben. Prüfen Sie Eigentümer und Rechte des Verzeichnisses.',
+      no_space: 'Das Dateisystem, auf dem {{path}} liegt, ist voll.',
+      not_a_directory: '{{path}} existiert, ist aber kein Verzeichnis.',
+      missing: '{{path}} existiert nicht und konnte nicht angelegt werden.',
+      error: 'Bambuddy kann nicht nach {{path}} schreiben.',
+      ephemeralTitle: 'Diese Backups überleben einen Container-Neustart nicht',
+      container_ephemeral: '{{path}} liegt im Bambuddy-Container, nicht auf dem Host. Dort geschriebene Backups gehen verloren, sobald der Container neu erstellt wird. Binden Sie das Verzeichnis vom Host ein:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/en.ts

@@ -4840,6 +4840,20 @@ export default {
     backupSize: 'Size',
     localTimeHint: 'Local time ({{tz}})',
     defaultPathLabel: 'Default:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy cannot write to this directory',
+      howToFix: 'How to fix:',
+      sandboxed: 'The Bambuddy service cannot write to {{path}}. Its systemd unit runs with ProtectSystem=strict, which makes every directory outside the install, data and log directories read-only for the service - even one you can write to from your own shell.',
+      read_only: '{{path}} is on a read-only filesystem.',
+      permission_denied: 'Bambuddy is not allowed to write to {{path}}. Check the directory owner and permissions.',
+      no_space: 'The filesystem holding {{path}} is full.',
+      not_a_directory: '{{path}} exists but is not a directory.',
+      missing: '{{path}} does not exist and could not be created.',
+      error: 'Bambuddy cannot write to {{path}}.',
+      ephemeralTitle: 'These backups will not survive a container restart',
+      container_ephemeral: '{{path}} is inside the Bambuddy container, not on the host. Backups written there are lost when the container is recreated. Mount the directory from the host:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/es.ts

@@ -4805,6 +4805,20 @@ export default {
     backupSize: 'Tamaño',
     localTimeHint: 'Hora local ({{tz}})',
     defaultPathLabel: 'Predeterminada:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy no puede escribir en este directorio',
+      howToFix: 'Cómo solucionarlo:',
+      sandboxed: 'El servicio de Bambuddy no puede escribir en {{path}}. Su unidad de systemd se ejecuta con ProtectSystem=strict, lo que hace que todo directorio fuera de los de instalación, datos y registros sea de solo lectura para el servicio, incluso uno en el que usted sí puede escribir desde su propia shell.',
+      read_only: '{{path}} está en un sistema de archivos de solo lectura.',
+      permission_denied: 'Bambuddy no tiene permiso para escribir en {{path}}. Compruebe el propietario y los permisos del directorio.',
+      no_space: 'El sistema de archivos que contiene {{path}} está lleno.',
+      not_a_directory: '{{path}} existe pero no es un directorio.',
+      missing: '{{path}} no existe y no se pudo crear.',
+      error: 'Bambuddy no puede escribir en {{path}}.',
+      ephemeralTitle: 'Estas copias de seguridad no sobrevivirán a la recreación del contenedor',
+      container_ephemeral: '{{path}} está dentro del contenedor de Bambuddy, no en el host. Las copias escritas ahí se pierden cuando se recrea el contenedor. Monte el directorio desde el host:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/fr.ts

@@ -4786,6 +4786,20 @@ export default {
     backupSize: 'Taille',
     localTimeHint: 'Heure locale ({{tz}})',
     defaultPathLabel: 'Par défaut :',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy ne peut pas écrire dans ce répertoire',
+      howToFix: 'Comment corriger :',
+      sandboxed: 'Le service Bambuddy ne peut pas écrire dans {{path}}. Son unité systemd s\'exécute avec ProtectSystem=strict, ce qui rend tout répertoire en dehors des répertoires d\'installation, de données et de journaux en lecture seule pour le service - même un répertoire dans lequel vous pouvez écrire depuis votre propre shell.',
+      read_only: '{{path}} se trouve sur un système de fichiers en lecture seule.',
+      permission_denied: 'Bambuddy n\'est pas autorisé à écrire dans {{path}}. Vérifiez le propriétaire et les permissions du répertoire.',
+      no_space: 'Le système de fichiers contenant {{path}} est plein.',
+      not_a_directory: '{{path}} existe mais n\'est pas un répertoire.',
+      missing: '{{path}} n\'existe pas et n\'a pas pu être créé.',
+      error: 'Bambuddy ne peut pas écrire dans {{path}}.',
+      ephemeralTitle: 'Ces sauvegardes ne survivront pas à la recréation du conteneur',
+      container_ephemeral: '{{path}} se trouve dans le conteneur Bambuddy, pas sur l\'hôte. Les sauvegardes qui y sont écrites sont perdues à la recréation du conteneur. Montez le répertoire depuis l\'hôte :',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/it.ts

@@ -4785,6 +4785,20 @@ export default {
     backupSize: 'Dimensione',
     localTimeHint: 'Ora locale ({{tz}})',
     defaultPathLabel: 'Predefinito:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy non riesce a scrivere in questa directory',
+      howToFix: 'Come risolvere:',
+      sandboxed: 'Il servizio Bambuddy non riesce a scrivere in {{path}}. La sua unit systemd usa ProtectSystem=strict, che rende ogni directory al di fuori di quelle di installazione, dati e log di sola lettura per il servizio - anche una in cui puoi scrivere dalla tua shell.',
+      read_only: '{{path}} si trova su un filesystem di sola lettura.',
+      permission_denied: 'Bambuddy non ha il permesso di scrivere in {{path}}. Controlla proprietario e permessi della directory.',
+      no_space: 'Il filesystem che contiene {{path}} è pieno.',
+      not_a_directory: '{{path}} esiste ma non è una directory.',
+      missing: '{{path}} non esiste e non è stato possibile crearla.',
+      error: 'Bambuddy non riesce a scrivere in {{path}}.',
+      ephemeralTitle: 'Questi backup non sopravvivono alla ricreazione del container',
+      container_ephemeral: '{{path}} si trova dentro il container Bambuddy, non sull\'host. I backup scritti lì vanno persi quando il container viene ricreato. Monta la directory dall\'host:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/ja.ts

@@ -4797,6 +4797,20 @@ export default {
     backupSize: 'サイズ',
     localTimeHint: '現地時刻 ({{tz}})',
     defaultPathLabel: 'デフォルト:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy はこのディレクトリに書き込めません',
+      howToFix: '対処方法:',
+      sandboxed: 'Bambuddy サービスは {{path}} に書き込めません。systemd ユニットが ProtectSystem=strict で動作しているため、インストール・データ・ログの各ディレクトリ以外はすべてサービスから読み取り専用になります。自分のシェルからは書き込める場所であっても同様です。',
+      read_only: '{{path}} は読み取り専用のファイルシステム上にあります。',
+      permission_denied: 'Bambuddy には {{path}} への書き込み権限がありません。ディレクトリの所有者とパーミッションを確認してください。',
+      no_space: '{{path}} があるファイルシステムに空き容量がありません。',
+      not_a_directory: '{{path}} は存在しますが、ディレクトリではありません。',
+      missing: '{{path}} は存在せず、作成もできませんでした。',
+      error: 'Bambuddy は {{path}} に書き込めません。',
+      ephemeralTitle: 'これらのバックアップはコンテナの再作成で失われます',
+      container_ephemeral: '{{path}} はホストではなく Bambuddy コンテナ内にあります。そこに書き込まれたバックアップはコンテナを再作成すると失われます。ホスト側のディレクトリをマウントしてください:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/ko.ts

@@ -4554,6 +4554,20 @@ export default {
     backupSize: '크기',
     localTimeHint: '현지 시간 ({{tz}})',
     defaultPathLabel: '기본값:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy가 이 디렉터리에 쓸 수 없습니다',
+      howToFix: '해결 방법:',
+      sandboxed: 'Bambuddy 서비스가 {{path}}에 쓸 수 없습니다. systemd 유닛이 ProtectSystem=strict로 실행되어 설치·데이터·로그 디렉터리를 제외한 모든 디렉터리가 서비스에서는 읽기 전용입니다. 사용자 셸에서는 쓸 수 있는 디렉터리라도 마찬가지입니다.',
+      read_only: '{{path}}이(가) 읽기 전용 파일 시스템에 있습니다.',
+      permission_denied: 'Bambuddy에 {{path}} 쓰기 권한이 없습니다. 디렉터리 소유자와 권한을 확인하세요.',
+      no_space: '{{path}}이(가) 있는 파일 시스템에 남은 공간이 없습니다.',
+      not_a_directory: '{{path}}이(가) 존재하지만 디렉터리가 아닙니다.',
+      missing: '{{path}}이(가) 존재하지 않으며 생성할 수도 없습니다.',
+      error: 'Bambuddy가 {{path}}에 쓸 수 없습니다.',
+      ephemeralTitle: '이 백업은 컨테이너를 다시 만들면 사라집니다',
+      container_ephemeral: '{{path}}은(는) 호스트가 아니라 Bambuddy 컨테이너 내부에 있습니다. 여기에 기록된 백업은 컨테이너를 재생성하면 사라집니다. 호스트의 디렉터리를 마운트하세요:',
+    },
     categories: {
       settings: '설정',
       notification_providers: '알림 제공자',

+ 14 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4785,6 +4785,20 @@ export default {
     backupSize: 'Tamanho',
     localTimeHint: 'Horário local ({{tz}})',
     defaultPathLabel: 'Padrão:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'O Bambuddy não consegue gravar neste diretório',
+      howToFix: 'Como corrigir:',
+      sandboxed: 'O serviço do Bambuddy não consegue gravar em {{path}}. Sua unit do systemd roda com ProtectSystem=strict, o que torna todo diretório fora dos diretórios de instalação, dados e logs somente leitura para o serviço - mesmo um em que você consegue gravar pelo seu próprio shell.',
+      read_only: '{{path}} está em um sistema de arquivos somente leitura.',
+      permission_denied: 'O Bambuddy não tem permissão para gravar em {{path}}. Verifique o dono e as permissões do diretório.',
+      no_space: 'O sistema de arquivos que contém {{path}} está cheio.',
+      not_a_directory: '{{path}} existe, mas não é um diretório.',
+      missing: '{{path}} não existe e não pôde ser criado.',
+      error: 'O Bambuddy não consegue gravar em {{path}}.',
+      ephemeralTitle: 'Estes backups não sobrevivem à recriação do contêiner',
+      container_ephemeral: '{{path}} está dentro do contêiner do Bambuddy, não no host. Backups gravados ali são perdidos quando o contêiner é recriado. Monte o diretório do host:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/tr.ts

@@ -4773,6 +4773,20 @@ export default {
     backupSize: 'Boyut',
     localTimeHint: 'Yerel saat ({{tz}})',
     defaultPathLabel: 'Varsayılan:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy bu dizine yazamıyor',
+      howToFix: 'Nasıl düzeltilir:',
+      sandboxed: 'Bambuddy servisi {{path}} dizinine yazamıyor. systemd birimi ProtectSystem=strict ile çalıştığı için kurulum, veri ve günlük dizinleri dışındaki her dizin servis açısından salt okunurdur - kendi kabuğunuzdan yazabildiğiniz bir dizin olsa bile.',
+      read_only: '{{path}} salt okunur bir dosya sisteminde.',
+      permission_denied: 'Bambuddy\'nin {{path}} dizinine yazma izni yok. Dizinin sahibini ve izinlerini kontrol edin.',
+      no_space: '{{path}} dizinini barındıran dosya sistemi dolu.',
+      not_a_directory: '{{path}} var ancak bir dizin değil.',
+      missing: '{{path}} yok ve oluşturulamadı.',
+      error: 'Bambuddy {{path}} dizinine yazamıyor.',
+      ephemeralTitle: 'Bu yedekler kapsayıcı yeniden oluşturulduğunda kaybolur',
+      container_ephemeral: '{{path}} ana makinede değil, Bambuddy kapsayıcısının içinde. Oraya yazılan yedekler kapsayıcı yeniden oluşturulduğunda kaybolur. Dizini ana makineden bağlayın:',
+    },
 
     categories: {
       settings: 'Ayarlar',

+ 14 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4785,6 +4785,20 @@ export default {
     backupSize: '大小',
     localTimeHint: '本地时间 ({{tz}})',
     defaultPathLabel: '默认:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy 无法写入该目录',
+      howToFix: '解决方法:',
+      sandboxed: 'Bambuddy 服务无法写入 {{path}}。它的 systemd 单元以 ProtectSystem=strict 运行,因此除安装、数据和日志目录之外的所有目录对服务而言都是只读的,即使你自己的 shell 可以写入也一样。',
+      read_only: '{{path}} 位于只读文件系统上。',
+      permission_denied: 'Bambuddy 无权写入 {{path}}。请检查该目录的属主和权限。',
+      no_space: '{{path}} 所在的文件系统已满。',
+      not_a_directory: '{{path}} 存在,但不是目录。',
+      missing: '{{path}} 不存在且无法创建。',
+      error: 'Bambuddy 无法写入 {{path}}。',
+      ephemeralTitle: '这些备份在容器重建后会丢失',
+      container_ephemeral: '{{path}} 位于 Bambuddy 容器内部,而不是宿主机上。写入其中的备份会在容器重建时丢失。请从宿主机挂载该目录:',
+    },
 
     // Category labels
     categories: {

+ 14 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4785,6 +4785,20 @@ export default {
     backupSize: '大小',
     localTimeHint: '本地時間 ({{tz}})',
     defaultPathLabel: '預設:',
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: 'Bambuddy 無法寫入此目錄',
+      howToFix: '解決方法:',
+      sandboxed: 'Bambuddy 服務無法寫入 {{path}}。其 systemd unit 以 ProtectSystem=strict 執行,因此安裝、資料與記錄目錄以外的所有目錄對服務而言都是唯讀的,即使你自己的 shell 可以寫入也一樣。',
+      read_only: '{{path}} 位於唯讀檔案系統上。',
+      permission_denied: 'Bambuddy 沒有寫入 {{path}} 的權限。請檢查該目錄的擁有者與權限。',
+      no_space: '{{path}} 所在的檔案系統已滿。',
+      not_a_directory: '{{path}} 存在,但不是目錄。',
+      missing: '{{path}} 不存在且無法建立。',
+      error: 'Bambuddy 無法寫入 {{path}}。',
+      ephemeralTitle: '這些備份在容器重建後會遺失',
+      container_ephemeral: '{{path}} 位於 Bambuddy 容器內,而非主機上。寫入其中的備份會在容器重建時遺失。請從主機掛載該目錄:',
+    },
 
     // Category labels
     categories: {

+ 37 - 1
install/install.sh

@@ -571,6 +571,33 @@ create_systemd_service() {
         protect_home="read-only"
     fi
 
+    # This function overwrites /etc/systemd/system/bambuddy.service outright. Any
+    # ReadWritePaths the operator added by hand — a NAS share for Scheduled
+    # Backups, typically — used to disappear with it, and the next backup failed
+    # with EROFS ("Read-only file system"), which reads like a permission problem
+    # and is not one (issue #2544). Back the old unit up and carry those paths
+    # forward.
+    local existing_unit="/etc/systemd/system/bambuddy.service"
+    local extra_rw=""
+    if [[ -f "$existing_unit" ]]; then
+        local backup_unit="${existing_unit}.bak-$(date +%Y%m%d-%H%M%S)"
+        sudo cp "$existing_unit" "$backup_unit"
+        log_info "Existing service backed up to $backup_unit"
+
+        local prev_rw
+        prev_rw=$(sudo grep -hE '^ReadWritePaths=' "$existing_unit" 2>/dev/null | sed 's/^ReadWritePaths=//' || true)
+        local p
+        for p in $prev_rw; do
+            case "$p" in
+                "$DATA_DIR" | "$LOG_DIR" | "$INSTALL_PATH") continue ;;
+            esac
+            extra_rw+=" $p"
+        done
+        if [[ -n "$extra_rw" ]]; then
+            log_info "Keeping custom writable paths from the previous service:$extra_rw"
+        fi
+    fi
+
     cat > /tmp/bambuddy.service << EOF
 [Unit]
 Description=BamBuddy - Bambu Lab Print Management
@@ -612,7 +639,16 @@ NoNewPrivileges=true
 PrivateTmp=true
 ProtectSystem=strict
 ProtectHome=$protect_home
-ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
+# ProtectSystem=strict makes EVERY path outside the ones below read-only for this
+# service — including a NAS share you mounted yourself and can write to from your
+# own shell. If you point Scheduled Backups at such a directory, add it here, or
+# better in a drop-in that survives a reinstall (#2544):
+#
+#   sudo systemctl edit bambuddy
+#   [Service]
+#   ReadWritePaths=/mnt/your-nas-share
+#
+ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH$extra_rw
 
 [Install]
 WantedBy=multi-user.target

+ 28 - 1
spoolbuddy/install/install.sh

@@ -756,6 +756,30 @@ create_bambuddy_directories() {
 create_bambuddy_service() {
     info "Creating Bambuddy systemd service..."
 
+    # Overwriting the unit used to silently drop any ReadWritePaths the operator
+    # had added — a NAS share for Scheduled Backups, typically — after which the
+    # backups failed with EROFS, which looks like a permission problem and is not
+    # one (issue #2544). Back the old unit up and carry those paths forward.
+    local existing_unit="/etc/systemd/system/bambuddy.service"
+    local extra_rw=""
+    if [[ -f "$existing_unit" ]]; then
+        local backup_unit="${existing_unit}.bak-$(date +%Y%m%d-%H%M%S)"
+        cp "$existing_unit" "$backup_unit"
+        info "Existing service backed up to $backup_unit"
+
+        local prev_rw p
+        prev_rw=$(grep -hE '^ReadWritePaths=' "$existing_unit" 2>/dev/null | sed 's/^ReadWritePaths=//' || true)
+        for p in $prev_rw; do
+            case "$p" in
+                "$INSTALL_PATH/data" | "$INSTALL_PATH/logs" | "$INSTALL_PATH") continue ;;
+            esac
+            extra_rw+=" $p"
+        done
+        if [[ -n "$extra_rw" ]]; then
+            info "Keeping custom writable paths from the previous service:$extra_rw"
+        fi
+    fi
+
     cat > /etc/systemd/system/bambuddy.service << EOF
 [Unit]
 Description=Bambuddy - Bambu Lab Print Management
@@ -788,7 +812,10 @@ NoNewPrivileges=true
 PrivateTmp=true
 ProtectSystem=strict
 ProtectHome=true
-ReadWritePaths=$INSTALL_PATH/data $INSTALL_PATH/logs $INSTALL_PATH
+# ProtectSystem=strict makes every path outside the ones below read-only for this
+# service. To back up to a NAS share, add it here or in a drop-in that survives a
+# reinstall: sudo systemctl edit bambuddy → [Service] → ReadWritePaths=/mnt/share
+ReadWritePaths=$INSTALL_PATH/data $INSTALL_PATH/logs $INSTALL_PATH$extra_rw
 
 [Install]
 WantedBy=multi-user.target

File diff suppressed because it is too large
+ 1 - 0
static/assets/index-DASc8Ke0.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-DaanvRDY.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-PHP-xpqR.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bb3jqp6t.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DaanvRDY.css">
+    <script type="module" crossorigin src="/assets/index-PHP-xpqR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DASc8Ke0.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff