diff --git a/.env b/.env deleted file mode 100644 index cee2f7b..0000000 --- a/.env +++ /dev/null @@ -1,5 +0,0 @@ -PG_DSN=postgresql://postgres:DFGk5s9H21lKao2K@136.243.41.58:7777/meles -S3_BUCKET=trapper-meles -S3_ACCESS_KEY=UG950FCGWFYRXSXXVUAH -S3_SECRET_KEY=rXpspNhboZY6zNZi7djjq2QaXPA4uwsO9jXf4AXk -S3_ENDPOINT=https://fsn1.your-objectstorage.com/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5909cd4 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# .env.example - Vorlage fuer die Secrets. +# +# Auf dem Server eine echte .env daraus erzeugen und mit den NEU ROTIERTEN +# Werten befuellen: +# cp .env.example .env +# chmod 600 .env +# Die echte .env wird durch .gitignore ausgeschlossen und darf NIE eingecheckt werden. + +# --- Hetzner Object Storage (S3) --- +S3_ENDPOINT=https://fsn1.your-objectstorage.com/ +S3_BUCKET=trapper-meles +S3_ACCESS_KEY=__NEUEN_ACCESS_KEY_EINTRAGEN__ +S3_SECRET_KEY=__NEUEN_SECRET_KEY_EINTRAGEN__ + +# --- PostgreSQL --- +# TLS erzwingen (sslmode=require). Host/Port zeigen auf den NEUEN DB-Zugang +# (idealerweise ueber privates Netz / SSH-Tunnel, nicht oeffentlich erreichbar). +PG_DSN=postgresql://USER:PASSWORT@HOST:PORT/meles?sslmode=require + +# --- DeerMapper API (nur noetig, wenn app.enable_deermapper_api: true) --- +DEERMAPPER_API_URL=https://webapp.deermapper.net/api/icu +DEERMAPPER_API_KEY=__NEUEN_API_KEY_EINTRAGEN__ diff --git a/.gitignore b/.gitignore index 0d1a15e..9e27200 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ *.pyc *.pyo __pycache__/ + +# Secrets - NIEMALS einchecken. Echte Werte leben nur in der lokalen .env auf dem Server. +.env +.env.* +!.env.example + +# Optionale lokale Config-Overrides (falls verwendet) +config.local.yaml diff --git a/Dockerfile b/Dockerfile index 55d48b2..fbac935 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,12 @@ WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt +# Code + nicht-geheime Konfiguration ins Image (Secrets kommen zur Laufzeit via ENV). COPY main.py /app/main.py +COPY config.yaml /app/config.yaml + +# Haertung: nicht als root laufen. +RUN useradd --create-home --uid 10001 appuser +USER appuser CMD ["python", "/app/main.py"] diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..90eea75 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,169 @@ +# Migration & Secret-Rotation - Runbook + +Ziel: `melesICUmover` sicher vom **Altserver** auf den **neuen Server** umziehen, +alle kompromittierten Zugangsdaten **rotieren**, den Altserver **stilllegen** und +die Secrets aus der **Git-History** entfernen. + +> Kontext: Der Poller haelt keine eigenen Daten - Bilder liegen in Hetzner S3 +> (`trapper-meles`), der Zustand in PostgreSQL. "Migration" heisst also: den +> **Worker-Container** umziehen und die **Credentials** erneuern. Es gibt keine +> Datenmigration. + +## Warum rotieren (nicht nur entfernen)? + +Folgende Secrets lagen im Klartext in `config.yaml` und `.env` **und in der +Git-History** (Commits `400f8a5`/`b35394c`) auf `git.meles.eu`. Sie gelten als +kompromittiert und muessen ersetzt werden: + +- S3 Access Key + Secret Key (Hetzner Object Storage) +- PostgreSQL-Passwort (`postgres@136.243.41.58:7777`) +- DeerMapper API-Key + +Das Bereinigen des Codes allein schuetzt nicht - die alten Werte bleiben gueltig, +bis sie widerrufen/geaendert werden. + +--- + +## Phase 0 - Vorbereitung (kein Ausfall) + +1. Auf dem **neuen Server** Docker + Compose pruefen/installieren: + ```bash + docker --version && docker compose version || curl -fsSL https://get.docker.com | sh + ``` +2. Repo auf den neuen Server holen (bereinigter Stand, ohne Secrets): + ```bash + git clone ssh://git.meles.eu/meles2/melesICUmover.git + cd melesICUmover + ``` +3. `.env` anlegen (noch NICHT starten): + ```bash + cp .env.example .env + chmod 600 .env + ``` + +## Phase 1 - S3-Key rotieren (kein Ausfall, additiv) + +Hetzner erlaubt mehrere S3-Credentials pro Projekt. Neuen Key **zusaetzlich** +anlegen, alten vorerst aktiv lassen: + +1. In der Hetzner Console -> Object Storage -> neuen S3-Zugang (Access/Secret) erzeugen. +2. In der `.env` auf dem neuen Server `S3_ACCESS_KEY` / `S3_SECRET_KEY` eintragen. + +## Phase 2 - DeerMapper API-Key rotieren + +Nur relevant, wenn `enable_deermapper_api: true` (aktuell `false`). Falls genutzt: +neuen Key beim DeerMapper-Betreiber anfordern und in die `.env` eintragen. Sonst +Feld leer lassen. + +## Phase 3 - Cutover (kurzer, geplanter Ausfall) + +Der PostgreSQL-Passwortwechsel trennt sofort den Altserver - daher hier gebuendelt: + +1. **Altserver-Poller stoppen** (kein Doppelbetrieb auf denselben Bucket/DB): + ```bash + # auf dem ALTSERVER + docker compose down # bzw. docker stop melesicumover + ``` +2. **PostgreSQL-Passwort aendern** (Beispiel; besser: eigene DB-Rolle fuer den Dienst): + ```sql + ALTER USER postgres WITH PASSWORD ''; + -- Empfohlen stattdessen: dedizierte Rolle mit minimalen Rechten auf remote_cam.* + -- CREATE ROLE icu_mover LOGIN PASSWORD '...'; + -- GRANT USAGE ON SCHEMA remote_cam TO icu_mover; + -- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA remote_cam TO icu_mover; + ``` +3. `PG_DSN` in der `.env` des neuen Servers setzen - **mit `?sslmode=require`** und, + sofern moeglich, ueber privates Netz / SSH-Tunnel statt der oeffentlichen IP. +4. **Neuen Server starten und verifizieren:** + ```bash + # auf dem NEUEN Server + docker compose up -d --build + docker compose logs -f # auf [RESULT] ...-Zeilen achten + ``` + Verifikation: + - Log zeigt verarbeitete UUIDs mit `result=success`. + - In S3 landen neue Objekte unter `icu/processed/` und `icu/thumbnails/`. + - `icu/entrance/` wird geleert (Cleanup laeuft). + - DB: `SELECT status, count(*) FROM remote_cam.import_job GROUP BY 1;` + +## Phase 4 - Altzugaenge widerrufen + +1. Alten S3-Key in der Hetzner Console **loeschen** (erst nachdem der neue Server + nachweislich laeuft). +2. Sicherstellen, dass der Altserver den neuen DB-Zugang **nicht** kennt. + +## Phase 5 - DB-Zugang absichern (Haertung) + +- PostgreSQL **nicht** auf `0.0.0.0:7777` oeffentlich anbieten. Firewall so setzen, + dass nur die IP des neuen Servers (oder ein privates Netz) den DB-Port erreicht. +- TLS erzwingen (`sslmode=require`), Hetzner Cloud Firewall bzw. `pg_hba.conf` pruefen. + +## Phase 6 - Altserver stilllegen / neu aufsetzen + +1. Sicherstellen: keine unersetzten Daten mehr lokal (der Poller hat keine). +2. Container + Images + lokale `.env`/`config.yaml` mit Secrets entfernen: + ```bash + # auf dem ALTSERVER + docker compose down --rmi all --volumes + shred -u .env 2>/dev/null || rm -f .env + ``` +3. Server neu aufsetzen bzw. deprovisionieren. Falls neu installiert: alte + SSH-/Deploy-Keys, die auf `git.meles.eu` Zugriff hatten, am Git-Server widerrufen. + +## Phase 7 - Git-History bereinigen (koordiniert, destruktiv) + +Erst **nach** erfolgreicher Rotation - danach sind die alten Werte ohnehin wertlos, +aber sie sollen auch nicht mehr auffindbar sein. + +> Warnung: Das schreibt die History um und erfordert `--force`-Push. Alle Personen +> mit Klon muessen anschliessend neu klonen. Vorher mit dem Team abstimmen. + +Mit [git filter-repo](https://github.com/newren/git-filter-repo) (empfohlen): + +```bash +# frisches Spiegel-Klon als Arbeitskopie +git clone ssh://git.meles.eu/meles2/melesICUmover.git repo-clean +cd repo-clean + +# .env komplett aus der History entfernen +git filter-repo --path .env --invert-paths + +# Secret-Werte auch aus historischen config.yaml-Versionen tilgen. +# Die vier ALTEN Werte NICHT hier ins Repo schreiben - die Datei liegt ausserhalb +# des Repos und wird nach dem Lauf geloescht. Werte aus der alten .env / History +# (git show 400f8a5:config.yaml) entnehmen: +cat > ../replacements.txt <<'EOF' +==>ENTFERNT +==>ENTFERNT +==>ENTFERNT +==>ENTFERNT +EOF +git filter-repo --replace-text ../replacements.txt + +# Remote neu setzen und History ueberschreiben +git remote add origin ssh://git.meles.eu/meles2/melesICUmover.git +git push --force --all origin +git push --force --tags origin +``` + +Danach `replacements.txt` loeschen. Alternative: BFG Repo-Cleaner. + +--- + +## Rollback + +Falls der neue Server Probleme macht, **bevor** Phase 4/6 abgeschlossen sind: +alten S3-Key noch aktiv lassen, altes PG-Passwort noch nicht wegwerfen, und den +Altserver-Poller wieder starten (`docker compose up -d`). Deshalb Altzugaenge erst +in Phase 4 widerrufen. + +## Abschluss-Checkliste + +- [ ] Neuer Server verarbeitet Bilder (`result=success`, S3 + DB aktualisiert) +- [ ] Neue S3-Keys aktiv, alte geloescht +- [ ] Neues PG-Passwort/-Rolle aktiv, `sslmode=require`, DB-Port nicht oeffentlich +- [ ] DeerMapper-Key rotiert (falls genutzt) +- [ ] Altserver-Poller gestoppt, `.env` sicher geloescht, Server neu aufgesetzt +- [ ] Alte SSH-/Deploy-Keys am Git-Server widerrufen +- [ ] Git-History bereinigt + force-push, Team informiert +- [ ] Repo enthaelt keine Secrets mehr (`git grep` auf HEAD + Stichprobe History) diff --git a/README.md b/README.md index 64cc1b1..170449f 100644 --- a/README.md +++ b/README.md @@ -1 +1,45 @@ -# melesICUmover \ No newline at end of file +# melesICUmover + +Dauer-Poller, der Wildkamera-Bilder aus einem Hetzner-S3-Bucket verarbeitet: +liest neue Bilder + `metadata.json` (+ optional `_categories.json`), extrahiert per +**OCR** (Tesseract) den Overlay-Zeitstempel, schreibt **EXIF**-Daten (exiftool), +legt verarbeitetes Bild + Thumbnail zurueck nach S3, persistiert alles nach +**PostgreSQL** (`remote_cam.*`), pusht optional an die **DeerMapper-API** und +raeumt den Entrance-Ordner auf. Zustand pro Bild in `remote_cam.import_job.status`. + +## Konfiguration + +Zweigeteilt - bewusst: + +| Datei | Inhalt | Im Git? | +|-------|--------|---------| +| `config.yaml` | Nicht-geheime App-Tunables + oeffentliche Endpunkte | **ja** (Template) | +| `.env` | Secrets: S3-Keys, `PG_DSN`, API-Key | **nein** (`.gitignore`) | + +Secrets werden **ausschliesslich** ueber Umgebungsvariablen geladen; die Werte in +`.env` ueberschreiben bzw. ergaenzen `config.yaml`. Beim Start prueft die App die +Vollstaendigkeit und bricht mit klarer Meldung ab, falls ein Secret fehlt. + +Unterstuetzte Umgebungsvariablen: `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, +`S3_SECRET_KEY`, `PG_DSN`, `DEERMAPPER_API_URL`, `DEERMAPPER_API_KEY`, `CONFIG_YAML`, +`DEBUG`. + +## Betrieb (Docker) + +```bash +cp .env.example .env # danach echte, rotierte Werte eintragen +chmod 600 .env +docker compose up -d --build +docker compose logs -f +``` + +## Sicherheitshinweise + +- **Niemals** Secrets in `config.yaml` oder ins Git schreiben - nur in `.env`. +- `PG_DSN` mit `?sslmode=require`; die Datenbank sollte **nicht** oeffentlich + erreichbar sein (privates Netz / Firewall / SSH-Tunnel). +- Debug-Port (`5678`) ist standardmaessig aus; bei Bedarf nur an `127.0.0.1` binden. +- Nach Kompromittierung (z.B. Secrets waren im Git): **rotieren**, nicht nur entfernen. + +Siehe [`MIGRATION.md`](MIGRATION.md) fuer Secret-Rotation, Umzug auf einen neuen +Server und Stilllegung des Altservers. diff --git a/config.yaml b/config.yaml index 656146a..8813e01 100644 --- a/config.yaml +++ b/config.yaml @@ -1,16 +1,22 @@ -# config.yaml (Beispiel) +# config.yaml - Nicht-geheime Konfiguration (App-Tunables + oeffentliche Endpunkte). +# +# WICHTIG: Hier gehoeren KEINE Zugangsdaten hinein. +# Secrets kommen ausschliesslich aus Umgebungsvariablen (.env): +# S3_ACCESS_KEY, S3_SECRET_KEY, PG_DSN, DEERMAPPER_API_KEY +# (optional koennen auch S3_ENDPOINT / S3_BUCKET / DEERMAPPER_API_URL per ENV +# ueberschrieben werden.) + s3: endpoint: "https://fsn1.your-objectstorage.com/" - access_key: "UG950FCGWFYRXSXXVUAH" - secret_key: "rXpspNhboZY6zNZi7djjq2QaXPA4uwsO9jXf4AXk" bucket: "trapper-meles" + # access_key / secret_key -> via ENV (.env): S3_ACCESS_KEY / S3_SECRET_KEY -postgres: - dsn: "postgresql://postgres:DFGk5s9H21lKao2K@136.243.41.58:7777/meles" +postgres: {} + # dsn -> via ENV (.env): PG_DSN (inkl. ?sslmode=require) deermapper-api: base_url: "https://webapp.deermapper.net/api/icu" - apiKey: "695bc217-3b40-48bb-bb12-17fc5b08b320" + # apiKey -> via ENV (.env): DEERMAPPER_API_KEY app: entrance_prefix: "icu/entrance/" diff --git a/docker-compose.yaml b/docker-compose.yaml index 68b7559..88d89fa 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,11 +3,18 @@ services: build: . container_name: melesicumover restart: unless-stopped + # Secrets werden aus der lokalen .env in die Container-Umgebung injiziert. + # Die .env liegt NICHT im Image und NICHT im Git (siehe .gitignore). + env_file: + - .env environment: CONFIG_YAML: /app/config.yaml DEBUG: "0" - ports: - - "5678:5678" - volumes: - - ./:/app - - ./config.yaml:/app/config.yaml:ro + # Kein Bind-Mount des Quellcodes mehr (der Code steckt im Image) und + # standardmaessig KEIN offener Debug-Port. + # + # Debugging nur bei Bedarf, lokal: + # 1) DEBUG oben auf "1" setzen + # 2) folgende Zeilen einkommentieren - bindet debugpy NUR an localhost: + # ports: + # - "127.0.0.1:5678:5678" diff --git a/main.py b/main.py index 4e97580..d28ba0a 100644 --- a/main.py +++ b/main.py @@ -101,22 +101,22 @@ def load_config(path: str) -> AppConfig: return value.strip().lower() in ("1", "true", "yes", "on") return bool(value) - s3 = raw["s3"] - pg = raw["postgres"] - app = raw.get("app", {}) - deermapper = raw.get("deermapper-api", raw.get("deermapper_api", {})) + s3 = raw.get("s3") or {} + pg = raw.get("postgres") or {} + app = raw.get("app") or {} + deermapper = raw.get("deermapper-api") or raw.get("deermapper_api") or {} default_workers = max(1, min(16, (os.cpu_count() or 2) * 2)) parallel_workers = int(app.get("parallel_workers", default_workers)) parallel_workers = max(1, parallel_workers) s3_pool = int(app.get("s3_max_pool_connections", max(16, parallel_workers * 4))) s3_pool = max(10, s3_pool) - return AppConfig( - s3_endpoint=env_or("S3_ENDPOINT", s3["endpoint"]), - s3_access_key=env_or("S3_ACCESS_KEY", s3["access_key"]), - s3_secret_key=env_or("S3_SECRET_KEY", s3["secret_key"]), - s3_bucket=env_or("S3_BUCKET", s3["bucket"]), - pg_dsn=env_or("PG_DSN", pg["dsn"]), + cfg = AppConfig( + s3_endpoint=env_or("S3_ENDPOINT", s3.get("endpoint", "")), + s3_access_key=env_or("S3_ACCESS_KEY", s3.get("access_key", "")), + s3_secret_key=env_or("S3_SECRET_KEY", s3.get("secret_key", "")), + s3_bucket=env_or("S3_BUCKET", s3.get("bucket", "")), + pg_dsn=env_or("PG_DSN", pg.get("dsn", "")), entrance_prefix=app.get("entrance_prefix", "icu/entrance/"), processed_prefix=app.get("processed_prefix", "icu/processed/"), thumb_prefix=app.get("thumb_prefix", "icu/thumbnails/"), @@ -138,6 +138,29 @@ def load_config(path: str) -> AppConfig: deermapper_api_image_field=app.get("deermapper_api_image_field", "image"), ) + # Fail-fast: Secrets kommen ausschliesslich aus der Umgebung (.env). Fehlen sie, + # bricht der Start mit klarer Meldung ab, statt spaeter kryptisch zu scheitern. + missing = [] + if not cfg.s3_endpoint: + missing.append("S3_ENDPOINT (oder s3.endpoint)") + if not cfg.s3_access_key: + missing.append("S3_ACCESS_KEY") + if not cfg.s3_secret_key: + missing.append("S3_SECRET_KEY") + if not cfg.s3_bucket: + missing.append("S3_BUCKET (oder s3.bucket)") + if not cfg.pg_dsn: + missing.append("PG_DSN") + if cfg.enable_deermapper_api and not cfg.deermapper_api_key: + missing.append("DEERMAPPER_API_KEY (enable_deermapper_api ist aktiv)") + if missing: + raise SystemExit( + "Fehlende Konfiguration/Secrets: " + ", ".join(missing) + + ". Bitte in der .env / als Umgebungsvariablen setzen." + ) + + return cfg + # ----------------------- # Helpers @@ -200,13 +223,18 @@ def ocr_extract_timestamp(jpg_bytes: bytes, crop_w_frac: float, crop_h_frac: flo bw = gray.point(lambda p: 255 if p > 180 else 0) - cfg = r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789:- ' - text = pytesseract.image_to_string(bw, config=cfg).strip() + tess_cfg = r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789:- ' + text = pytesseract.image_to_string(bw, config=tess_cfg).strip() - # retry without thresholding - text2 = pytesseract.image_to_string(gray, config=cfg).strip() - - dt_local_naive = _parse_ocr_datetime(text, text2) + try: + # Schnellpfad: der Schwellwert-Durchlauf reicht meist -> zweiten (teuren) + # OCR-Lauf einsparen. + dt_local_naive = _parse_ocr_datetime(text) + except ValueError: + # Fallback: ohne Thresholding erneut lesen und beide Durchlaeufe parsen + # (identisches Verhalten wie zuvor). + text2 = pytesseract.image_to_string(gray, config=tess_cfg).strip() + dt_local_naive = _parse_ocr_datetime(text, text2) # Keep camera-local timezone (no UTC conversion requested) return dt_local_naive.replace(tzinfo=customer_tzinfo) @@ -383,28 +411,37 @@ def db_init(cur): updated_ts timestamptz NOT NULL DEFAULT now() ); """) - cur.execute(""" - ALTER TABLE remote_cam.import_job - ADD COLUMN IF NOT EXISTS needs_ocr_backfill boolean NOT NULL DEFAULT false; - """) - cur.execute(""" - ALTER TABLE remote_cam.import_job - ADD COLUMN IF NOT EXISTS needs_exif_backfill boolean NOT NULL DEFAULT false; - """) - cur.execute("CREATE INDEX IF NOT EXISTS import_job_needs_ocr_idx ON remote_cam.import_job(needs_ocr_backfill);") - cur.execute("CREATE INDEX IF NOT EXISTS import_job_needs_exif_idx ON remote_cam.import_job(needs_exif_backfill);") - cur.execute("CREATE INDEX IF NOT EXISTS import_job_status_idx ON remote_cam.import_job(status);") - cur.execute("CREATE INDEX IF NOT EXISTS import_job_updated_idx ON remote_cam.import_job(updated_ts);") + for col, typedef in [ + ("needs_ocr_backfill", "boolean NOT NULL DEFAULT false"), + ("needs_exif_backfill", "boolean NOT NULL DEFAULT false"), + ]: + cur.execute(""" + SELECT 1 FROM information_schema.columns + WHERE table_schema='remote_cam' AND table_name='import_job' AND column_name=%s + """, (col,)) + if not cur.fetchone(): + cur.execute(f"ALTER TABLE remote_cam.import_job ADD COLUMN {col} {typedef};") + for idx, col in [ + ("import_job_needs_ocr_idx", "needs_ocr_backfill"), + ("import_job_needs_exif_idx", "needs_exif_backfill"), + ("import_job_status_idx", "status"), + ("import_job_updated_idx", "updated_ts"), + ]: + cur.execute("SELECT 1 FROM pg_indexes WHERE schemaname='remote_cam' AND indexname=%s", (idx,)) + if not cur.fetchone(): + cur.execute(f"CREATE INDEX {idx} ON remote_cam.import_job({col});") - cur.execute(""" - CREATE OR REPLACE FUNCTION remote_cam.set_updated_ts() - RETURNS trigger AS $$ - BEGIN - NEW.updated_ts = now(); - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - """) + cur.execute("SELECT 1 FROM pg_proc JOIN pg_namespace ON pg_proc.pronamespace=pg_namespace.oid WHERE nspname='remote_cam' AND proname='set_updated_ts'") + if not cur.fetchone(): + cur.execute(""" + CREATE FUNCTION remote_cam.set_updated_ts() + RETURNS trigger AS $$ + BEGIN + NEW.updated_ts = now(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) cur.execute(""" DO $$ BEGIN