initial commit

This commit is contained in:
2026-08-04 21:02:28 +02:00
parent fc1bbe2a85
commit d14690c525
27 changed files with 1810 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
# SPEC: iCloud Contacts Sync (v2)
## 1. Zweck
Automatisierter, wiederkehrender Delta-Sync mehrerer iCloud-Accounts
(CardDAV) in eine gemeinsame MariaDB-Instanz (`mariadb.internal`),
inklusive täglichem Mailversand für heutige Geburtstage. Ziel ist eine
vollständige, queryfähige Kopie aller Kontaktdaten mehrerer Apple-IDs
außerhalb des Apple-Ökosystems.
## 2. Architektur
```
+-------------------+ CardDAV (HTTPS, Basic Auth, je Account) +----------------------+
| icloud-contacts- | -------------------------------------------> | contacts.icloud.com |
| sync Container | +----------------------+
| (Docker-Host auf |
| Proxmox-Host) | MySQL Protocol (TCP 3306)
| | -------------------------------------------> mariadb.internal
| |
| | SMTP (Port 587, STARTTLS)
| | -------------------------------------------> SMTP-Relay
+-------------------+
```
- Ein Container verarbeitet sequenziell alle in `config/accounts.yml`
konfigurierten Apple-IDs, jeweils isoliert mit eigenem sync-token und
eigenem `account`-Feld in der Datenbank.
- Zwei unabhängige Cron-Jobs innerhalb desselben Containers:
Kontakt-Sync (alle 15 Minuten) und Geburtstags-Mailer (täglich,
konfigurierbare Uhrzeit).
- Zeitsteuerung über `supercronic`, Crontab wird beim Container-Start
dynamisch aus `MAIL_SEND_HOUR` generiert.
## 3. Multi-User-Konfiguration
- Datei `config/accounts.yml` (gemountet, nicht im Image, nicht im Git,
siehe `.gitignore`), Struktur:
```yaml
accounts:
- name: markus
apple_email: markus@icloud.com
apple_app_password: "xxxx-xxxx-xxxx-xxxx"
- name: partner
apple_email: partner@icloud.com
apple_app_password: "yyyy-yyyy-yyyy-yyyy"
```
- `name` ist der interne, eindeutige Account-Bezeichner und wird 1:1 als
`account`-Spalte in `contacts`, `sync_state` und `sync_runs`
gespeichert.
- Jeder Account wird beim Sync-Lauf unabhängig verarbeitet: ein
Fehler bei einem Account (z. B. abgelaufenes App-Passwort) bricht den
Lauf für andere Accounts nicht ab.
- Die Datei liegt bewusst separat von `.env`, da sie mehrere
Credential-Sets enthält und sich unabhängig von der übrigen
Konfiguration versionieren/rotieren lässt.
## 4. Delta-Sync über CardDAV sync-collection (RFC 6578)
- Für jeden Account wird nach der Collection-Discovery ein
`REPORT sync-collection` mit dem zuletzt gespeicherten `sync-token`
ausgeführt; der Server liefert nur geänderte, neue und gelöschte
Kontakte seit diesem Token zurück.
- Der neue `sync-token` wird nach jedem erfolgreichen Lauf pro Account
in `sync_state` gespeichert.
- **Initialer Lauf**: Existiert noch kein Token, wird einmalig ein
vollständiger Abruf per `addressbook-query` durchgeführt
(`sync_type = 'initial'` in `sync_runs`), anschließend wird der erste
`sync-token` gespeichert.
- **Token-Ablauf**: iCloud-Tokens sind laut Beobachtung ca. 29 Tage
gültig. Lehnt der Server einen Token ab (`403 valid-sync-token`),
löscht der Client den gespeicherten Token und führt automatisch einen
vollen Re-Sync durch, ohne manuellen Eingriff.
- Gelöschte Kontakte werden über `404`-Status-Einträge in der
sync-collection-Antwort erkannt (Href-basiert) und gezielt aus
MariaDB entfernt, es findet kein pauschales Löschen aller Kontakte
mehr statt (Unterschied zu v1).
- Vorteil bei 2.000+ Kontakten: reguläre 15-Minuten-Läufe übertragen nur
die tatsächlichen Änderungen, nicht den kompletten Bestand.
## 5. Datenmodell (MariaDB)
Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
- `contacts.account` zusätzliche Spalte, Eindeutigkeit jetzt über
`(account, uid)` statt `(uid, source)`, damit identische UIDs in
unterschiedlichen Apple-IDs nicht kollidieren.
- Neue Tabelle `sync_state`: ein Datensatz pro Account mit dem
aktuellen `sync_token`.
- `sync_runs` erweitert um `account`, `sync_type`
(`initial`/`delta`), `contacts_upserted`, `contacts_deleted`.
- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem
erfolgreich eine Geburtstagsmail versendet wurde, verhindert
Doppelversand bei mehrfachem Container-Neustart am selben Tag.
## 6. Geburtstags-Mailer
- Eigenständiges Skript `src/mailer.py`, läuft im selben Container über
einen zweiten Cron-Eintrag, täglich zur in `MAIL_SEND_HOUR`
konfigurierten Stunde (Default 7 Uhr).
- Query: alle Kontakte über alle Accounts hinweg, deren `birthday`
(Monat/Tag) auf das heutige Datum fällt.
- Versand per SMTP mit STARTTLS (`smtplib`), Konfiguration über
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`,
`MAIL_TO`.
- Idempotenz: vor dem Versand wird `birthday_mail_log` auf einen
Eintrag für den heutigen Tag geprüft; existiert bereits einer, wird
der Lauf ohne erneuten Versand beendet.
- Feature-Flag `MAILER_ENABLED` erlaubt das komplette Deaktivieren ohne
Codeänderung.
- E-Mail-Inhalt aktuell reiner Text (Name, Alter, Account), HTML-Format
ist als spätere Erweiterung denkbar, aber nicht im Scope.
## 7. Konfiguration (Umgebungsvariablen)
| Variable | Pflicht | Beschreibung |
|-----------------------|---------|---------------------------------------------------|
| MARIADB_HOST | nein | Default: mariadb.internal |
| MARIADB_PORT | nein | Default: 3306 |
| MARIADB_DATABASE | nein | Default: contacts |
| MARIADB_USER | ja | DB-Benutzer mit Schreibrechten |
| MARIADB_PASSWORD | ja | Passwort des DB-Benutzers |
| ACCOUNTS_CONFIG_PATH | nein | Default: /app/config/accounts.yml |
| LOG_LEVEL | nein | Default: INFO |
| MAILER_ENABLED | nein | Default: true, deaktiviert Mailer bei false |
| SMTP_HOST | ja (Mailer) | SMTP-Relay-Host |
| SMTP_PORT | nein | Default: 587 |
| SMTP_USER | nein | leer, falls Relay ohne Auth |
| SMTP_PASSWORD | nein | leer, falls Relay ohne Auth |
| SMTP_USE_TLS | nein | Default: true |
| MAIL_FROM | ja (Mailer) | Absenderadresse |
| MAIL_TO | ja (Mailer) | Empfängeradresse(n) |
| MAIL_SEND_HOUR | nein | Default: 7, Stunde (0-23) für täglichen Mailversand |
Secrets werden weiterhin als klassische Umgebungsvariablen übergeben,
mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.yml`
gelesen werden (per Volume-Mount, nicht im Image, nicht im Git).
## 8. Container-Image
- Basis: `python:3.12-slim`, Zeitsteuerung über `supercronic`.
- Zwei Cron-Einträge im dynamisch generierten Crontab: Sync (`*/15`)
und Mailer (`0 <MAIL_SEND_HOUR> * * *`).
- Läuft als non-root User (`syncuser`, UID 10001).
- `HEALTHCHECK` prüft weiterhin die Marker-Datei des letzten
erfolgreichen Sync-Laufs.
## 9. CI/CD (GitHub Actions)
Unverändert gegenüber v1:
- `build-and-push.yml`: Build und Push nach `ghcr.io` bei Push auf
`main`, Tags `latest` und Kurz-SHA.
- Nachgelagerter `cleanup`-Job über `dataaxiom/ghcr-cleanup-action`,
behält die letzten 4 getaggten Images, löscht ungetaggte Artefakte,
`latest` von der Zählung ausgenommen.
- `lint.yml`: Ruff-Check auf Pull Requests.
## 10. Betrieb auf der Docker-Host
- `docker-compose.yml` mountet `config/accounts.yml` read-only in den
Container und übergibt DB- sowie SMTP-Zugangsdaten per `.env`.
- Für private GHCR-Packages weiterhin einmaliger `docker login ghcr.io`
mit PAT (Scope `read:packages`) nötig.
## 11. Geplante spätere Erweiterungen (nicht in diesem Repo)
- **Web-Ansicht + API**: separates Container-Image (z. B. FastAPI +
einfaches Frontend), liest ausschließlich aus derselben MariaDB,
schreibt nicht in die `contacts`-Tabelle, um Konflikte mit dem
Sync-Container zu vermeiden. Kann als eigenes Repository nach
demselben Muster (Dockerfile, GitHub Actions, ghcr.io) aufgebaut
werden.
- **Weitere Quellen**: Google Contacts und Microsoft 365 nach
demselben Account-Muster (eigene `source`-Werte, eigene
Sync-Strategie je Anbieter-API).
- **HTML-Mails, mehrere Empfänger je Kontakt, Vorlauf-Erinnerungen**
(z. B. "in 3 Tagen") sind funktional einfach nachrüstbar, aktuell
aber nicht Teil des Scopes.
## 12. Web-Ansicht und API (v3, im selben Repo/Image)
Ursprünglich als separates Projekt geplant, jetzt bewusst ins selbe
Repository und Image integriert, da Codebasis (Config, DB-Layer) ohnehin
geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
### 12.1 Ein Image, mehrere Rollen
- Das Dockerfile bleibt unverändert eines für alle Zwecke: es enthält
sowohl `src/sync.py`, `src/mailer.py` als auch das komplette
`src/api/`-Package.
- `docker-compose.yml` definiert zwei Services aus demselben Image:
- `icloud-contacts-sync`: Standard-Entrypoint, startet `supercronic`
mit Sync- und Mailer-Cron (unverändert zu v2).
- `icloud-contacts-api`: überschreibt `command` komplett mit
`uvicorn api.main:app`, ignoriert den Cron-Entrypoint des Images.
- Beide Services teilen sich dieselbe MariaDB und dieselbe
`config/accounts.json`, der API-Service greift ausschließlich lesend
auf `contacts`, `sync_runs` zu, schreibt nichts.
### 12.2 Authelia-Integration
- Die API selbst implementiert kein Login. Sie geht davon aus, dass ein
vorgeschalteter Reverse-Proxy (nginx/Traefik) mit Authelia via
`auth_request` bereits authentifiziert hat und den Benutzernamen im
Header `Remote-User` an den Container weiterreicht.
- Der Header-Name ist über `AUTH_REMOTE_USER_HEADER` konfigurierbar,
falls dein Setup einen anderen Namen verwendet (z. B.
`X-Forwarded-User`).
- `api/auth.py` liest diesen Header per FastAPI-`Header`-Dependency; ist
er nicht gesetzt, antwortet die API mit 401, da dies bedeutet, dass
der Zugriff nicht über den Authelia-geschützten Pfad erfolgte.
### 12.3 Accounts-Mapping (`authelia_user`)
- Jeder Account in `accounts.json` bekommt optional ein Feld
`authelia_user`, das den Authelia-Benutzernamen mit dem internen
Account-Namen verknüpft.
- Beim Request wird der Remote-User-Header gegen dieses Mapping
aufgelöst: der Benutzer sieht ausschließlich die Kontakte seines
eigenen Accounts, alle Queries werden serverseitig mit
`WHERE account = %s` eingeschränkt.
- Ein zusätzliches Top-Level-Feld `admins` (Liste von
`authelia_user`-Werten) erlaubt bestimmten Benutzern uneingeschränkten
Zugriff auf alle Accounts, z. B. für dich als Betreiber.
- Ist ein eingeloggter Authelia-User weder gemappt noch Admin, antwortet
die API mit 403.
### 12.4 Endpunkte
| Endpunkt | Beschreibung |
|---|---|
| `GET /` | Einfache HTML-Übersicht (Jinja2-Template), zeigt Kontakte des zugeordneten Accounts |
| `GET /api/health` | Health-Check ohne Auth-Anforderung |
| `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` |
| `GET /api/contacts/{id}` | Einzelner Kontakt |
| `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) |
| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
### 12.5 Netzwerkkontext
- Der API-Container bindet den Port nur an `127.0.0.1:8000`, ist also
auf der Docker-Host selbst nicht von außen erreichbar.
- Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit
Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf
`127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt.