fix: service draait als uid 1000 (eddy), eigenaar van de exfat-SSD

Root was bad practice (oude staat); aiaiaiaiagent (uid 1001) kon geen
chmod/utime op de exfat-mount (uid=1000) -> elke copy2-kopie faalde met
EPERM (10-15, 0/68). Correcte laag is de identity-laag, niet de kopieer-
primitive: User=eddy (mount-eigenaar). copy2 behouden (timestamps op
doel). Matrix-secrets sherpa-token/eddy-report-room -> mode 640 group
eddy + traverse op /home/aiaiaiaiagent zodat de notificatie werkt.

Revert eerdere copyfile-workaround (dd46ad6/69d08ba): die maskeerde de
mismatch in plaats van de principal te aligneren.
This commit is contained in:
Eddy de Vink 2026-10-15 20:25:50 +02:00
commit 0461fbc074
3 changed files with 28 additions and 25 deletions

View file

@ -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 verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart
resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist. resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist.
- **Retry per bestand** — 3 pogingen met 2s pauze; vangt transiënte USB-glitches. - **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 - **Early-abort** — bij 5 opeenvolgende fouten stopt het script en stuurt het een
Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder
geverifieerde kopie. 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; - **fsck.exfat** — na mount draait het mount-script een read-only health check;
een dirty/defecte kaart wordt daarmee zichtbaar in syslog. een dirty/defecte kaart wordt daarmee zichtbaar in syslog.
- **Geen dubbele runs** — het mount-script skipt als de transfer al actief is. - **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 - **Stale-mount opruiming** — als de kaartlezer wordt uitgetrokken (of de kaart
offline klapt) ruimt een udev REMOVE-trigger de mount op: de service wordt 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 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 | | `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 | | 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) | | 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 | | Abort: onvoldoende vrije ruimte | SSD vol, opschonen hielp niet | Oude datum-mappen handmatig opschonen of SSD uitbreiden |
### Herstelprocedures ### Herstelprocedures
@ -232,8 +233,8 @@ watchdog-rapportage, Home Assistant en Uptime Kuma):
|---|---| |---|---|
| API | `https://whatsed.eddydevink.nl/_matrix/client/v3` | | API | `https://whatsed.eddydevink.nl/_matrix/client/v3` |
| Bot-account | `@sherpa:whatsed.eddydevink.nl` (compat-token `mct_…`) | | Bot-account | `@sherpa:whatsed.eddydevink.nl` (compat-token `mct_…`) |
| Tokenbestand | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` (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 600) | | Roombestand | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` (mode 640, group eddy) |
| Bericht | `PUT /rooms/<id>/send/m.room.message/…` met `m.text`-event | | Bericht | `PUT /rooms/<id>/send/m.room.message/…` met `m.text`-event |
Token en room-ID blijven buiten Git; de bestanden worden bij elke notificatie gelezen. Token en room-ID blijven buiten Git; de bestanden worden bij elke notificatie gelezen.
@ -294,7 +295,9 @@ After=mnt-sdcard.mount
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/dashcam-transfer 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 ### Logging

View file

@ -150,16 +150,16 @@ def ensure_free_space(needed_bytes, target_base):
def copy_file(src, dst, attempts=MAX_RETRIES, delay=RETRY_DELAY): def copy_file(src, dst, attempts=MAX_RETRIES, delay=RETRY_DELAY):
"""Copy src → dst with retry. Raises the last OSError when all attempts fail.""" """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 last_error = None
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
try: try:
# copyfile (niet copy/copy2): die doen ook metadata-operaties op het shutil.copy2(src, dst)
# 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)
return return
except OSError as e: except OSError as e:
last_error = e last_error = e

View file

@ -85,22 +85,22 @@ class CopyFileTest(unittest.TestCase):
self.assertEqual(os.path.getsize(self.dst), 100) self.assertEqual(os.path.getsize(self.dst), 100)
def test_retries_then_succeeds(self): def test_retries_then_succeeds(self):
real_copy = dashcam.shutil.copyfile real_copy2 = dashcam.shutil.copy2
calls = {"n": 0} calls = {"n": 0}
def flaky(s, d): def flaky(s, d):
calls["n"] += 1 calls["n"] += 1
if calls["n"] < 3: if calls["n"] < 3:
raise OSError(errno.EIO, "I/O error") 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) dashcam.copy_file(self.src, self.dst, attempts=3, delay=0)
self.assertEqual(calls["n"], 3) self.assertEqual(calls["n"], 3)
self.assertEqual(os.path.getsize(self.dst), 100) self.assertEqual(os.path.getsize(self.dst), 100)
def test_raises_after_exhausting_attempts(self): 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): with self.assertRaises(OSError):
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0) 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): def test_verification_mismatch_counts_as_error_and_keeps_source(self):
files = [self._make("20260622050346_0000001F.MP4", 100)] files = [self._make("20260622050346_0000001F.MP4", 100)]
def bad_copy(s, d): def bad_copy2(s, d):
with open(d, "wb") as f: with open(d, "wb") as f:
f.write(b"0" * 50) 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" dashcam.time, "sleep"
): ):
ok, errors = dashcam.transfer_files(files, self.src, self.base) 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) for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3)
] ]
with mock.patch.object( 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"): ), mock.patch.object(dashcam.time, "sleep"):
with self.assertRaises(dashcam.TransferAborted) as ctx: with self.assertRaises(dashcam.TransferAborted) as ctx:
dashcam.transfer_files(files, self.src, self.base) dashcam.transfer_files(files, self.src, self.base)