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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:51:45 +02:00
co-authored by Claude Opus 5
parent 773033936d
commit a62c51b7c7
7 changed files with 316 additions and 5 deletions
+32
View File
@@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [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 - **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und
kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()` kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()`
+47
View File
@@ -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
+23
View File
@@ -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"]
+71 -1
View File
@@ -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 für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
Kontrollwert, der keine Geheimnisse enthält. Kontrollwert, der keine Geheimnisse enthält.
## Einrichten ## Einrichten mit Docker (empfohlen)
Auf dem **Gegenbuch-Rechner** – nicht auf dem CRM-Server:
```bash
git clone <dieses Repository> 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/<instanz>/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: Auf einem **anderen** Rechner als dem CRM-Server:
+55
View File
@@ -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
+70
View File
@@ -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
+18 -4
View File
@@ -221,14 +221,22 @@ async function hole(pfad) {
return j.data; 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 = () => 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 // Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, // signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. // sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
const REWIND_VORBEHALT = () => const REWIND_VORBEHALT = () =>
REWIND_GESCHUETZT || ffBelegt REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL
? '' ? ''
: '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
'lässt sich hier nicht ausschließen.' + 'lässt sich hier nicht ausschließen.' +
@@ -251,6 +259,11 @@ const REWIND_VORBEHALT = () =>
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
const abschliessendSicher = (anzahl) => const abschliessendSicher = (anzahl) =>
REWIND_GESCHUETZT || 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 // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur
// den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten. // den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten.
// Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein // Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein
@@ -305,8 +318,9 @@ if (hatRemote) {
process.exit(3); process.exit(3);
} }
} else if (process.env.NOTARY_ALLOW_LOCAL === 'true') { } else if (process.env.NOTARY_ALLOW_LOCAL === 'true') {
// Der Schalter darf nicht zu einem glatten „OK“ fuehren: Leckt er je ins // Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner,
// Produktivumfeld, gaebe es gruenes Licht auf nicht notarisiertem Stand. // ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das
// abdeckt und was nicht – ein glattes „OK“ ohne Einordnung gibt es nicht.
NUR_LOKAL = true; NUR_LOKAL = true;
} else { } else {
console.error( console.error(