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
+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
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:
+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;
}
// 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(