diff --git a/README.md b/README.md index 753f2a8..6d7bfb9 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,6 @@ SD-kaartlezer die midden in de transfer offline klapt, `I/O error`, `device offl verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist. - **Retry per bestand** — 3 pogingen met 2s pauze; vangt transiënte USB-glitches. -- **`shutil.copyfile` i.p.v. `copy`/`copy2`** — `copy`/`copy2` doen na het kopiëren - ook metadata-operaties op het doel (`chmod`, `utime`). Op de exfat-SSD - (`uid=1000` → álle bestanden zijn eigendom van uid 1000, `allow_utime=0022`) - worden die geweigerd voor een niet-eigenaar (EPERM); sinds de service als - `aiaiaiaiagent` draait (voorheen root) faalde daardoor elke kopie. `copyfile` - schrijft alleen data; mode is op exfat altijd 0777 en het opnamemoment staat al - in de bestandsnaam — metadata-operaties voegen niets toe. - **Early-abort** — bij 5 opeenvolgende fouten stopt het script en stuurt het een Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder geverifieerde kopie. @@ -62,6 +55,14 @@ SD-kaartlezer die midden in de transfer offline klapt, `I/O error`, `device offl - **fsck.exfat** — na mount draait het mount-script een read-only health check; een dirty/defecte kaart wordt daarmee zichtbaar in syslog. - **Geen dubbele runs** — het mount-script skipt als de transfer al actief is. +- **Service-user = data-eigenaar (uid 1000)** — de exfat-mounts draaien als + `uid=1000,gid=1000`, dus álle bestanden op de SSD zijn eigendom van uid 1000 + (`eddy`). De unit draait als `User=eddy`; dat is noodzakelijk want copy2 doet + `chmod`/`utime` op het doel, en dat mag alleen de eigenaar. Dit was eerder + fout opgebouwd (eerst `root` — bad practice; daarna `aiaiaiaiagent` — leidde + tot EPERM op élke kopie). Wie de unit-user aanpast: alleen naar een uid dat + eigenaar is van de mount, anders breekt elke transfer met + `Operation not permitted`. - **Stale-mount opruiming** — als de kaartlezer wordt uitgetrokken (of de kaart offline klapt) ruimt een udev REMOVE-trigger de mount op: de service wordt gestopt en `/mnt/sdcard` netjes ontkoppeld (lazy als dat mislukt). Zo blijft er @@ -159,7 +160,7 @@ dmesg | grep -Ei "usb|sd[a-z]|device offline|I/O error" | `Geen MP4 bestanden gevonden` in log | Kaart leeg (transfer geslaagd) of verkeerde mapstructuur | Niks mis; controleer op de SSD of bestanden er zijn | | Transfer start niet na insteken | Service draait al (is-active gate) of device verscheen niet binnen 60s | Logs checken; als het device nooit komt, lezer opnieuw insteken | | Stale mount in `mount`-output | REMOVE-trigger miste (lezer trok in één keer beide devices weg) | Cleanup-script handmatig draaien (zie herstel) | -| `Matrix notificatie mislukt` in log | sherpa-token/room-file ontbreken of Matrix-homeserver onbereikbaar | `~/whatsed-new/secrets/sherpa-token` + `eddy-report-room` controleren (mode 600), `https://whatsed.eddydevink.nl` checken | +| `Matrix notificatie mislukt` in log | sherpa-token/room-file onleesbaar voor service-user of Matrix-homeserver onbereikbaar | `~/whatsed-new/secrets/sherpa-token` + `eddy-report-room` controleren (mode 640, group eddy; service draait als uid 1000), `https://whatsed.eddydevink.nl` checken | | Abort: onvoldoende vrije ruimte | SSD vol, opschonen hielp niet | Oude datum-mappen handmatig opschonen of SSD uitbreiden | ### Herstelprocedures @@ -232,8 +233,8 @@ watchdog-rapportage, Home Assistant en Uptime Kuma): |---|---| | API | `https://whatsed.eddydevink.nl/_matrix/client/v3` | | Bot-account | `@sherpa:whatsed.eddydevink.nl` (compat-token `mct_…`) | -| Tokenbestand | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` (mode 600) | -| Roombestand | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` (mode 600) | +| Tokenbestand | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` (mode 640, group eddy — leesbaar door service-user eddy) | +| Roombestand | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` (mode 640, group eddy) | | Bericht | `PUT /rooms//send/m.room.message/…` met `m.text`-event | Token en room-ID blijven buiten Git; de bestanden worden bij elke notificatie gelezen. @@ -294,7 +295,9 @@ After=mnt-sdcard.mount [Service] Type=oneshot ExecStart=/usr/local/bin/dashcam-transfer -User=aiaiaiaiagent +# uid 1000 (eddy): eigenaar van de exfat-SSD (mount uid=1000) — copy2 heeft +# eigenaarschap nodig voor chmod/utime. Niet root, niet een andere user. +User=eddy ``` ### Logging diff --git a/dashcam-transfer.py b/dashcam-transfer.py index bbc4b1b..d6b9c32 100644 --- a/dashcam-transfer.py +++ b/dashcam-transfer.py @@ -150,16 +150,16 @@ def ensure_free_space(needed_bytes, target_base): def copy_file(src, dst, attempts=MAX_RETRIES, delay=RETRY_DELAY): """Copy src → dst with retry. Raises the last OSError when all attempts fail.""" + # copy2 preserveert timestamps (os.utime) en mode (os.chmod) op het doel. + # Dat vereist eigenaarschap van het doelbestand: de exfat-mount draait als + # uid=1000 en álle bestanden op de SSD zijn eigendom van uid 1000. De unit + # draait daarom als User=eddy (uid 1000) — NIET als root, NIET als een + # andere gebruiker (chmod/utime → EPERM voor niet-eigenaars). Bestand → + # copyfile omwisselen zou deze invariant verbergen en opnieuw breken. last_error = None for attempt in range(1, attempts + 1): try: - # copyfile (niet copy/copy2): die doen ook metadata-operaties op het - # doel — chmod en utime. Op exfat met mountuid=1000 zijn álle bestanden - # eigendom van uid 1000, en allow_utime=0022 + chmod-toestand weigeren - # dat voor de niet-eigenaar service-gebruiker (EPERM op elke kopie). - # copyfile schrijft alleen data; mode is op exfat altijd 0777 en het - # opnamemoment staat al in de bestandsnaam (YYYYMMDDHHMMSS). - shutil.copyfile(src, dst) + shutil.copy2(src, dst) return except OSError as e: last_error = e diff --git a/tests/test_dashcam_transfer.py b/tests/test_dashcam_transfer.py index 5ab2973..c8c32d2 100644 --- a/tests/test_dashcam_transfer.py +++ b/tests/test_dashcam_transfer.py @@ -85,22 +85,22 @@ class CopyFileTest(unittest.TestCase): self.assertEqual(os.path.getsize(self.dst), 100) def test_retries_then_succeeds(self): - real_copy = dashcam.shutil.copyfile + real_copy2 = dashcam.shutil.copy2 calls = {"n": 0} def flaky(s, d): calls["n"] += 1 if calls["n"] < 3: raise OSError(errno.EIO, "I/O error") - real_copy(s, d) + real_copy2(s, d) - with mock.patch.object(dashcam.shutil, "copyfile", side_effect=flaky): + with mock.patch.object(dashcam.shutil, "copy2", side_effect=flaky): dashcam.copy_file(self.src, self.dst, attempts=3, delay=0) self.assertEqual(calls["n"], 3) self.assertEqual(os.path.getsize(self.dst), 100) def test_raises_after_exhausting_attempts(self): - with mock.patch.object(dashcam.shutil, "copyfile", side_effect=OSError(errno.EIO, "I/O error")): + with mock.patch.object(dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error")): with self.assertRaises(OSError): dashcam.copy_file(self.src, self.dst, attempts=2, delay=0) @@ -155,11 +155,11 @@ class TransferFilesTest(unittest.TestCase): def test_verification_mismatch_counts_as_error_and_keeps_source(self): files = [self._make("20260622050346_0000001F.MP4", 100)] - def bad_copy(s, d): + def bad_copy2(s, d): with open(d, "wb") as f: f.write(b"0" * 50) - with mock.patch.object(dashcam.shutil, "copyfile", side_effect=bad_copy), mock.patch.object( + with mock.patch.object(dashcam.shutil, "copy2", side_effect=bad_copy2), mock.patch.object( dashcam.time, "sleep" ): ok, errors = dashcam.transfer_files(files, self.src, self.base) @@ -172,7 +172,7 @@ class TransferFilesTest(unittest.TestCase): for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3) ] with mock.patch.object( - dashcam.shutil, "copyfile", side_effect=OSError(errno.EIO, "I/O error") + dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error") ), mock.patch.object(dashcam.time, "sleep"): with self.assertRaises(dashcam.TransferAborted) as ctx: dashcam.transfer_files(files, self.src, self.base)