From a62c51b7c7b428a85c403b863e092b28e1c34853 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 22 Aug 2026 18:51:45 +0200 Subject: [PATCH] Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer eigenen Maschine fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal. Die Trennung, auf die es ankommt, ist damit gegeben - wer OpenCRM uebernimmt, kommt nicht ans Buch. Richtung bewusst so herum: Das Gegenbuch holt ueber HTTPS mit einem Token, das nur audit:read kann. OpenCRM kennt weder Adresse noch Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig. tools/audit-notary/ enthaelt jetzt Dockerfile, entrypoint.sh, docker-compose.yml und .env.example. Zwei Dienste (prod, staging) mit getrennten Verzeichnissen und Schluesseln, gesteuert ueber COMPOSE_PROFILES - dasselbe Muster wie beim Caddy-Profil im Hauptstack. Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine erzeugt. Lokaler Betrieb ist jetzt ein vollwertiger Modus statt eines Testschalters. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist und was nicht - statt der frueheren pauschalen Formulierung "kein Manipulationsschutz", die im Einsatz auf eigener Maschine schlicht falsch war. Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Normalbetrieb exit 0; Eintrag veraendert -> Alarm exit 2; Eintraege geloescht -> Alarm exit 2; Siegel verschwunden -> Alarm. Behoben beim Bauen: useradd -u 1000 || true verschluckte, dass UID 1000 im Node-Image vergeben ist - der Container startete gar nicht. Co-Authored-By: Claude Opus 5 --- docs/todo.md | 32 ++++++++++++ tools/audit-notary/.env.example | 47 +++++++++++++++++ tools/audit-notary/Dockerfile | 23 +++++++++ tools/audit-notary/README.md | 72 ++++++++++++++++++++++++++- tools/audit-notary/docker-compose.yml | 55 ++++++++++++++++++++ tools/audit-notary/entrypoint.sh | 70 ++++++++++++++++++++++++++ tools/audit-notary/notary.mjs | 22 ++++++-- 7 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 tools/audit-notary/.env.example create mode 100644 tools/audit-notary/Dockerfile create mode 100644 tools/audit-notary/docker-compose.yml create mode 100755 tools/audit-notary/entrypoint.sh diff --git a/docs/todo.md b/docs/todo.md index 8113f3a6..1f7b3508 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22) + - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine** + fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch + liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist + damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch. + - Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem + Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch + Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann + ausschliesslich Pruefwerte lesen. + - Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`, + `docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit + getrennten Verzeichnissen und Schluesseln, gesteuert ueber + `COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack. + Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine + erzeugt und verlaesst sie nie. + - **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter + mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist + („jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen + Rechner hier“) – statt der frueheren pauschalen Formulierung „kein + Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch + war. + - Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser + `exit=0` gehoert angesehen. + - Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne + Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb + exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege + geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden → + Alarm. Testartefakte und Image geloescht. + - Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000 + im Node-Image bereits vergeben ist – der Container startete gar nicht. + Jetzt laeuft er als der vorhandene Benutzer `node`. + - [x] **🧮 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18) - **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()` diff --git a/tools/audit-notary/.env.example b/tools/audit-notary/.env.example new file mode 100644 index 00000000..f2cd8784 --- /dev/null +++ b/tools/audit-notary/.env.example @@ -0,0 +1,47 @@ +# ============================================================ +# Gegenbuch für OpenCRM +# ============================================================ +# Diese Datei gehört auf den Gegenbuch-Rechner – NICHT auf den CRM-Server. +# +# Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von +# OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im +# CRM etwas nachträglich verändert, widerspricht das dem Buch. +# +# Welche Bücher sollen laufen? +COMPOSE_PROFILES=prod,staging + +# Nur Kosmetik – steht in den Einträgen des Buchs. +NOTAR_EMAIL=gegenbuch@example.de + +# ---------------- Produktion ---------------- +# Von welcher OpenCRM-Instanz wird geholt? +PROD_CRM_URL=https://crm.example.de + +# Token eines Benutzers mit dem Recht audit:read – MEHR NICHT. +# Damit lassen sich ausschließlich Prüfwerte lesen: keine Kundendaten, keine +# Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen. +PROD_CRM_TOKEN= + +# Wie oft geprüft wird (Sekunden). 3600 = stündlich. +# Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe. +PROD_INTERVAL=3600 + +# Beim ALLERERSTEN Start einmalig auf true setzen, danach wieder leeren. +# Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt – das +# soll nicht versehentlich passieren. +PROD_GENESIS_ACK= + +# Normalerweise leer lassen. Nur nötig, wenn das Gedächtnis des Gegenbuchs +# verlorenging (z. B. Verzeichnis gelöscht) und du geklärt hast, warum. +PROD_ADOPT_ACK= + +# Wo das Buch liegt. DIESES VERZEICHNIS GEHÖRT INS BACKUP. +PROD_DIR=./data/prod + +# ---------------- Test / Staging ---------------- +STAGING_CRM_URL=https://staging.example.de +STAGING_CRM_TOKEN= +STAGING_INTERVAL=3600 +STAGING_GENESIS_ACK= +STAGING_ADOPT_ACK= +STAGING_DIR=./data/staging diff --git a/tools/audit-notary/Dockerfile b/tools/audit-notary/Dockerfile new file mode 100644 index 00000000..63edeb5f --- /dev/null +++ b/tools/audit-notary/Dockerfile @@ -0,0 +1,23 @@ +# Gegenbuch – läuft auf einem EIGENEN Rechner, nicht dort, wo OpenCRM läuft. +# +# Der Container holt lesend einen Kontrollwert vom CRM (HTTPS + Token mit +# audit:read) und legt seine Einträge in einem Git-Repository auf DIESER +# Maschine ab. Er braucht keinen SSH-Zugang zum CRM, keine Datenbank, keine +# Shell dort. Umgekehrt kennt OpenCRM dieses Gegenbuch überhaupt nicht. +FROM node:20-slim + +# git für das Buch, openssh-client für die Signaturen (ssh-keygen). +RUN apt-get update && apt-get install -y --no-install-recommends \ + git openssh-client ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY notary.mjs /opt/notary/notary.mjs +COPY entrypoint.sh /opt/notary/entrypoint.sh +RUN chmod +x /opt/notary/entrypoint.sh + +# Nicht als root laufen. Das Node-Image bringt bereits einen Benutzer mit +# UID 1000 mit ("node") – der passt zu Bind-Mounts unter ./data/. +USER node +WORKDIR /gegenbuch + +ENTRYPOINT ["/opt/notary/entrypoint.sh"] diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 64e2ba72..56d78d04 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -18,7 +18,77 @@ der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren Kontrollwert, der keine Geheimnisse enthält. -## Einrichten +## Einrichten mit Docker (empfohlen) + +Auf dem **Gegenbuch-Rechner** – nicht auf dem CRM-Server: + +```bash +git clone opencrm +cd opencrm/tools/audit-notary +cp .env.example .env +# .env ausfüllen: CRM-Adresse und Token eintragen +docker compose up -d +``` + +Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder +leeren) – die erste Eintragung legt fest, was als Ausgangszustand gilt, und das +soll nicht versehentlich passieren. + +**Zwei Bücher auf einer Maschine** sind vorgesehen: `prod` und `staging` sind +getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln. +Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`. + +### Wer redet mit wem + +``` +Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read) +OpenCRM ─────────────────> (kennt das Gegenbuch nicht) +``` + +Das ist der eigentliche Schutz. Das Gegenbuch **holt** – es lässt sich nichts +schicken. OpenCRM kennt weder Adresse noch Schlüssel des Gegenbuchs. Wer +OpenCRM übernimmt, kommt hier nicht heran. + +Das Token kann ausschließlich Prüfwerte lesen: keine Kundendaten, keine +Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen. + +Der Signaturschlüssel wird beim ersten Start **auf dem Gegenbuch-Rechner +erzeugt** und verlässt ihn nie. + +### Was ins Backup gehört + +Das Datenverzeichnis (`./data/prod` bzw. `./data/staging`). Darin liegen das +Buch, der Schlüssel und der Beobachtungsspeicher. Geht es verloren, beginnt die +Beobachtung von vorn – und der nächste Lauf sagt das ausdrücklich, statt „alles +gut" zu melden. + +### Überwachung + +Jeder Durchlauf schreibt seinen Stand nach `data//status.txt`: + +``` +2026-08-22T16:49:50+00:00 exit=0 in Ordnung +``` + +**Alles außer `exit=0` gehört angesehen.** Wer eine Überwachung hat, greift +diese Datei ab; wer keine hat, schaut regelmäßig mit `docker compose logs` +hinein. Ein Alarm, den niemand liest, ist keiner. + +### Was dieser Betrieb abdeckt – und was nicht + +**Abgedeckt:** Jemand verändert oder löscht nachträglich Einträge im CRM – +auch mit direktem Datenbankzugriff. Das widerspricht dem Gegenbuch und fällt +beim nächsten Durchlauf auf. + +**Nicht abgedeckt:** Jemand übernimmt den Gegenbuch-Rechner selbst. Dagegen +hülfe nur eine zusätzliche Ablage außerhalb (z. B. ein privates Git-Repository +auf einem dritten Server) – das ist vorbereitet, aber für die meisten +Installationen mehr Aufwand als Nutzen. Die Erfolgsmeldung benennt diese Grenze +bei jedem Lauf, damit sie nicht in Vergessenheit gerät. + +--- + +## Einrichten ohne Docker Auf einem **anderen** Rechner als dem CRM-Server: diff --git a/tools/audit-notary/docker-compose.yml b/tools/audit-notary/docker-compose.yml new file mode 100644 index 00000000..2a2d4198 --- /dev/null +++ b/tools/audit-notary/docker-compose.yml @@ -0,0 +1,55 @@ +# Gegenbuch für OpenCRM +# ===================== +# Gehört auf einen EIGENEN Rechner – nicht dorthin, wo OpenCRM läuft. +# +# Richtung der Verbindung (das ist der eigentliche Schutz): +# Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read) +# OpenCRM ─────────────────> (kennt das Gegenbuch nicht) +# +# Wer OpenCRM übernimmt, kommt damit nicht an dieses Buch heran. Deshalb holt +# das Gegenbuch selbst, statt sich etwas schicken zu lassen. +# +# Start: +# cp .env.example .env # Werte eintragen +# docker compose up -d +# +# Zwei Bücher auf einer Maschine sind vorgesehen (Produktion und Test): zwei +# getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln. +# Welche laufen, steuert COMPOSE_PROFILES in der .env. + +services: + prod: + build: . + container_name: gegenbuch-prod + restart: unless-stopped + profiles: ["prod"] + environment: + INSTANZ: prod + CRM_URL: ${PROD_CRM_URL} + CRM_TOKEN: ${PROD_CRM_TOKEN} + NOTARY_INTERVAL: ${PROD_INTERVAL:-3600} + NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} + # Nur beim allerersten Lauf einmalig auf true, danach wieder leeren: + NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-} + # Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README: + NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-} + volumes: + # Buch, Schlüssel, Beobachtungsspeicher und Statusdatei. + # Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup. + - ${PROD_DIR:-./data/prod}:/gegenbuch + + staging: + build: . + container_name: gegenbuch-staging + restart: unless-stopped + profiles: ["staging"] + environment: + INSTANZ: staging + CRM_URL: ${STAGING_CRM_URL} + CRM_TOKEN: ${STAGING_CRM_TOKEN} + NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600} + NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} + NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} + NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-} + volumes: + - ${STAGING_DIR:-./data/staging}:/gegenbuch diff --git a/tools/audit-notary/entrypoint.sh b/tools/audit-notary/entrypoint.sh new file mode 100755 index 00000000..a0d56534 --- /dev/null +++ b/tools/audit-notary/entrypoint.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Gegenbuch-Schleife: einrichten, dann in festem Takt beglaubigen. +set -uo pipefail + +: "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}" +: "${CRM_URL:?CRM_URL fehlt – von welcher OpenCRM-Instanz soll geholt werden?}" +: "${CRM_TOKEN:?CRM_TOKEN fehlt – Token eines Benutzers mit audit:read}" + +INTERVALL="${NOTARY_INTERVAL:-3600}" +BUCH=/gegenbuch/buch +SCHLUESSEL=/gegenbuch/schluessel +export NOTARY_STATE_FILE=/gegenbuch/beobachtungen.jsonl +export NOTARY_ALLOW_LOCAL=true # Buch liegt auf dieser Maschine +export NOTARY_PUSH=false # es gibt kein Ziel zum Pushen + +echo "[$INSTANZ] Gegenbuch startet – Quelle: $CRM_URL, Takt: ${INTERVALL}s" + +# --- Signaturschlüssel: einmalig auf DIESER Maschine erzeugen --------------- +# Er verlässt den Rechner nie. OpenCRM kennt ihn nicht. +mkdir -p "$SCHLUESSEL" +if [ ! -f "$SCHLUESSEL/id_ed25519" ]; then + echo "[$INSTANZ] Erzeuge Signaturschlüssel..." + ssh-keygen -t ed25519 -f "$SCHLUESSEL/id_ed25519" -N "" -C "gegenbuch-$INSTANZ" -q +fi +chmod 600 "$SCHLUESSEL/id_ed25519" +if [ ! -f "$SCHLUESSEL/allowed_signers" ]; then + echo "gegenbuch-$INSTANZ namespaces=\"git\" $(cat "$SCHLUESSEL/id_ed25519.pub")" \ + > "$SCHLUESSEL/allowed_signers" +fi + +# --- Buch anlegen, falls noch nicht vorhanden ------------------------------ +if [ ! -d "$BUCH/.git" ]; then + echo "[$INSTANZ] Lege Gegenbuch an..." + mkdir -p "$BUCH" + git -C "$BUCH" init -q -b main +fi +cd "$BUCH" || exit 1 + +git config user.email "${NOTAR_EMAIL:-gegenbuch@localhost}" +git config user.name "Gegenbuch $INSTANZ" +git config gpg.format ssh +git config user.signingkey "$SCHLUESSEL/id_ed25519.pub" +git config gpg.ssh.allowedSignersFile "$SCHLUESSEL/allowed_signers" + +# Ein erster Commit, damit es eine Historie gibt, an die angehängt werden kann. +if ! git rev-parse HEAD >/dev/null 2>&1; then + git commit -q -S --allow-empty -m "Gegenbuch $INSTANZ angelegt" +fi + +# --- Schleife --------------------------------------------------------------- +# Jeder Rückgabecode außer 0 gehört angesehen. Der letzte Stand steht +# zusätzlich in status.txt, damit eine Überwachung ihn abgreifen kann, ohne +# Logs zu durchsuchen. +while true; do + ZEIT=$(date -Iseconds) + node /opt/notary/notary.mjs + CODE=$? + case $CODE in + 0) LAGE="in Ordnung" ;; + 1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;; + 2) LAGE="BEFUND – Widerspruch zwischen CRM und Gegenbuch" ;; + 3) LAGE="nicht abschliessend feststellbar (u. a. erster Lauf)" ;; + 4) LAGE="Gedaechtnis fehlt – Bestaetigung noetig, siehe README" ;; + 5) LAGE="Anker unvollstaendig" ;; + *) LAGE="unbekannter Code" ;; + esac + echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)" + printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE" > /gegenbuch/status.txt + sleep "$INTERVALL" +done diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 6b5e2fb2..9e01b240 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -221,14 +221,22 @@ async function hole(pfad) { return j.data; } +// Lokaler Betrieb ist ein vollwertiger Modus, wenn das Gegenbuch auf einem +// EIGENEN Rechner laeuft: Wer das CRM uebernimmt, kommt dann nicht an dieses +// Buch. Was fehlt, ist der Schutz gegen jemanden, der DIESEN Rechner +// uebernimmt – das gehoert benannt, aber es ist kein „kein Schutz“. const OHNE_ANKER = () => - NUR_LOKAL ? ' – ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : ''; + NUR_LOKAL + ? '\n Betriebsart: lokal. Geschützt ist damit der Fall „jemand verändert Daten im ' + + 'CRM“.\n NICHT geschützt ist der Fall „jemand übernimmt diesen Rechner hier“ – ' + + 'dagegen hülfe\n nur eine zusätzliche Ablage außerhalb.' + : ''; // Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt // signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, // sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. const REWIND_VORBEHALT = () => - REWIND_GESCHUETZT || ffBelegt + REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL ? '' : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + 'lässt sich hier nicht ausschließen.' + @@ -251,6 +259,11 @@ const REWIND_VORBEHALT = () => // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. const abschliessendSicher = (anzahl) => REWIND_GESCHUETZT || + // Im lokalen Betrieb gibt es keinen Remote, den jemand zurueckspulen koennte. + // Der Vergleich gegen den eigenen Beobachtungsspeicher ist hier der + // vollstaendige Nachweis – die Grenze liegt bei der Integritaet dieses + // Rechners und ist als solche benannt, nicht ungeklaert. + (NUR_LOKAL && ffBelegt) || // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur // den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten. // Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein @@ -305,8 +318,9 @@ if (hatRemote) { process.exit(3); } } else if (process.env.NOTARY_ALLOW_LOCAL === 'true') { - // Der Schalter darf nicht zu einem glatten „OK“ fuehren: Leckt er je ins - // Produktivumfeld, gaebe es gruenes Licht auf nicht notarisiertem Stand. + // Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner, + // ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das + // abdeckt und was nicht – ein glattes „OK“ ohne Einordnung gibt es nicht. NUR_LOKAL = true; } else { console.error(