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
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/<id>/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

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):
"""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

View file

@ -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)