Compare commits

..

3 Commits

Author SHA1 Message Date
dom
399913abe3 Merge pull request 'Security: remove hardcoded credentials, fix db_init for non-owner users' (#1) from security/remove-hardcoded-secrets into main 2026-07-14 09:44:22 +00:00
94bfc60fc4 Fix db_init for non-owner DB users
ALTER TABLE, CREATE INDEX and CREATE OR REPLACE FUNCTION all require table/function
ownership in PostgreSQL. Replace IF NOT EXISTS DDL with explicit existence checks so
db_init works for unprivileged application users where the schema was created by a
different role (e.g. postgres).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 08:58:46 +00:00
88ef0d6943 Remove hardcoded credentials, harden deployment, optimize OCR
Secrets (S3 keys, PG password, DeerMapper API key) were committed in
config.yaml and .env and remain in git history. This removes them from
the tracked tree and moves all secrets to env injection.

Security:
- config.yaml: drop all credentials, keep only non-secret app tunables
- untrack .env, add .env.example template; .gitignore excludes .env
- main.py: tolerant config lookups + fail-fast validation for missing secrets
- docker-compose: env_file injection, no full-repo bind mount, debug port off
- Dockerfile: bake config into image, run as non-root user

Efficiency:
- OCR: run the second (expensive) tesseract pass only when the first
  is unparsable; identical fallback behavior

Docs:
- README with operation + security notes
- MIGRATION.md runbook: secret rotation, server cutover, decommission,
  git history purge

Note: the leaked secrets are compromised and MUST be rotated; removing
them from the tree is not sufficient. See MIGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:35:04 +02:00
9 changed files with 348 additions and 54 deletions

5
.env
View File

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

22
.env.example Normal file
View File

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

8
.gitignore vendored
View File

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

View File

@@ -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"]

169
MIGRATION.md Normal file
View File

@@ -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 '<NEUES_STARKES_PASSWORT>';
-- 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'
<ALTES_PG_PASSWORT>==>ENTFERNT
<ALTES_S3_SECRET_KEY>==>ENTFERNT
<ALTER_S3_ACCESS_KEY>==>ENTFERNT
<ALTER_DEERMAPPER_API_KEY>==>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)

View File

@@ -1 +1,45 @@
# 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.

View File

@@ -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/"

View File

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

91
main.py
View File

@@ -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,12 +223,17 @@ 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()
# retry without thresholding
text2 = pytesseract.image_to_string(gray, 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()
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)
@@ -383,21 +411,30 @@ def db_init(cur):
updated_ts timestamptz NOT NULL DEFAULT now()
);
""")
for col, typedef in [
("needs_ocr_backfill", "boolean NOT NULL DEFAULT false"),
("needs_exif_backfill", "boolean NOT NULL DEFAULT false"),
]:
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);")
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("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 OR REPLACE FUNCTION remote_cam.set_updated_ts()
CREATE FUNCTION remote_cam.set_updated_ts()
RETURNS trigger AS $$
BEGIN
NEW.updated_ts = now();