Compose-Vorlage, EspoCRM-Grundeinstellungen und Render-Skript

Die Vorlage ist fuer alle Kunden identisch und traegt genau einen Platzhalter
(__FQDN__); bin/espo-compose-rendern setzt ihn ein und schreibt
/srv/espo/docker-compose.yml. Keine .env mehr: alles ausser den Geheimnissen
steht in der Compose selbst.

Inhalt der Vorlage: Bind-Mounts unter /srv/espo, Datei-Secrets fuer die beiden
MariaDB-Passwoerter, Record-IDs als uuid4/36 ueber configs (in allen drei
EspoCRM-Diensten, weil configs nicht ueber volumes_from vererbt werden),
adminUpgradeDisabled, WebSocket ueber wss://<fqdn>/wss, Healthchecks mit
Startfenstern fuer den Migrationslauf.

ESPO-02 setzt keine abweichende Bindeadresse mehr - die Ports muessen fuer den
Reverse Proxy erreichbar sein. Stattdessen beschraenkt eine DOCKER-USER-Regel
den Zugang auf das eigene Subnetz, das die VM selbst ermittelt; eine
systemd-Unit setzt sie nach jedem Docker-Start neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gordon.zimm 2026-09-23 14:22:59 +02:00
parent 81d86ba363
commit 996490075a
5 changed files with 309 additions and 10 deletions

View File

@ -8,6 +8,7 @@ es steht deshalb **kein Passwort, kein Token und keine Kundenangabe** in diesem
| `autoinstall/` | NoCloud-Seed für die Basis-VM (`user-data`, `meta-data`) |
| `rmm-tasks/` | Skripte, die als Task im ConnectWise RMM hinterlegt sind (Quelle für Änderungen) |
| `bin/` | Skripte, die auf der VM unter `/srv/espo/bin/` liegen und von Tasks aufgerufen werden |
| `compose/` | Compose-Vorlage der Kundeninstanz und die EspoCRM-Grundeinstellungen (`config-override.php`) |
Konzept, Entscheidungen und Runbooks liegen im internen Repository `espoCRM/espo-fleet-ops`.
@ -19,7 +20,14 @@ autoinstall ds=nocloud-net\;s=https://gitea.subcura.net/EspoCRM_Public/espo-depl
Das Semikolon muss maskiert werden – in GRUB trennt ein unmaskiertes `;` zwei Befehle.
## Compose
`compose/docker-compose.vorlage.yml` ist für alle Kunden identisch und enthält genau einen Platzhalter: `__FQDN__`.
`bin/espo-compose-rendern <fqdn>` setzt ihn ein und schreibt `/srv/espo/docker-compose.yml`. Diese Datei auf der VM ist
ein **Erzeugnis** – sie wird bei jedem Lauf neu geschrieben, Änderungen von Hand gehen verloren. Geändert wird die
Vorlage hier im Repository.
## Kundenspezifisches
Gehört **nicht** hierher, sondern in die Custom Fields des RMM (FQDN, Ring, Wartungsfenster) und in die `.env` auf der
jeweiligen VM. Geheimnisse entstehen auf der VM und verlassen sie nicht.
Gehört **nicht** hierher, sondern in die Custom Fields des RMM (FQDN, Ring, Wartungsfenster). Geheimnisse entstehen auf
der VM (`/srv/espo/secrets/`, 0600) und verlassen sie nicht.

57
bin/espo-compose-rendern Executable file
View File

@ -0,0 +1,57 @@
#!/bin/bash
# espo-compose-rendern - erzeugt /srv/espo/docker-compose.yml aus der versionierten Vorlage.
# Einziger kundenspezifischer Wert ist der FQDN; er kommt aus dem Custom Field espo_fqdn.
# Aufruf: espo-compose-rendern <fqdn> (von ESPO-10 und ESPO-30)
# Idempotent: gleiche Eingabe, gleiche Datei; geschrieben wird nur bei Unterschied.
set -u
VORLAGE_URL=https://gitea.subcura.net/EspoCRM_Public/espo-deploy/raw/branch/main/compose/docker-compose.vorlage.yml
ZIEL=/srv/espo/docker-compose.yml
FQDN="${1:-}"
# 1. Ohne gueltigen FQDN waeren Site-URL und WebSocket-Adresse falsch - hier abbrechen, nicht spaeter raten.
if ! printf '%s' "$FQDN" | grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$'; then
echo "FEHLER: kein gueltiger FQDN uebergeben ('$FQDN')"
echo "ESPO_COMPOSE_ERGEBNIS: FEHLER"; exit 1
fi
# 2. Vorlage holen - geheimnisfrei und ohne Anmeldung abrufbar.
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
curl -fsSL "$VORLAGE_URL" -o "$TMP/vorlage.yml" || {
echo "FEHLER: Vorlage nicht abrufbar ($VORLAGE_URL)"
echo "ESPO_COMPOSE_ERGEBNIS: FEHLER"; exit 1; }
# 3. Platzhalter ersetzen.
sed "s|__FQDN__|${FQDN}|g" "$TMP/vorlage.yml" > "$TMP/neu.yml"
# 4. Kein Platzhalter darf uebrig bleiben, sonst startete Docker mit einer unfertigen Datei.
OFFEN="$(grep -oE '__[A-Z][A-Z0-9_]*__' "$TMP/neu.yml" | sort -u | tr '\n' ' ')"
if [ -n "$OFFEN" ]; then
echo "FEHLER: unersetzte Platzhalter: $OFFEN"
echo "ESPO_COMPOSE_ERGEBNIS: FEHLER"; exit 1
fi
# 5. Die drei EspoCRM-Dienste teilen ueber volumes_from denselben Code - verschiedene Images waeren ein Fehler.
if [ "$(grep -E '^[[:space:]]+image: espocrm' "$TMP/neu.yml" | sort -u | wc -l)" -ne 1 ]; then
echo "FEHLER: die EspoCRM-Dienste verwenden verschiedene Images"
echo "ESPO_COMPOSE_ERGEBNIS: FEHLER"; exit 1
fi
# 6. Nur bei Unterschied schreiben, damit wiederholte Laeufe nichts anfassen.
install -d -m 0755 /srv/espo
if [ -f "$ZIEL" ] && cmp -s "$TMP/neu.yml" "$ZIEL"; then
STATUS=UNVERAENDERT
else
install -m 0644 "$TMP/neu.yml" "$ZIEL"
STATUS=GEAENDERT
fi
# 7. Docker selbst pruefen lassen, sobald die Geheimnisdateien liegen - startet nichts.
if [ -r /srv/espo/secrets/db_password ]; then
docker compose -f "$ZIEL" config -q || {
echo "FEHLER: Compose-Datei nicht gueltig"
echo "ESPO_COMPOSE_ERGEBNIS: FEHLER"; exit 1; }
fi
echo "$ZIEL $STATUS (FQDN $FQDN)"
echo "ESPO_COMPOSE_ERGEBNIS: OK"

View File

@ -0,0 +1,67 @@
<?php
/**
* subcura – Grundeinstellungen für EspoCRM-Instanzen (deutscher Markt), unabhängig von Extensions.
*
* Mechanismus: EspoCRMs eigene data/config-override.php (ab v8.2) – wird nach config.php und config-internal.php geladen,
* überschreibt nur die hier gesetzten Schlüssel und wird von der Anwendung nie geschrieben (Änderungen an diesen
* Parametern über die Admin-UI bleiben wirkungslos). getenv() ist erlaubt.
* Anwendung: Kunden-VM: ESPO-10 legt die Datei vor dem ersten Start nach /srv/espo/data/config-override.php
* (Bind-Mount des EspoCRM-data-Verzeichnisses); danach Cache leeren.
* Entwicklungs- und Integrationsinstanzen: siehe espo-dev-env, Abschnitt 10.
* Nicht hier: instanzspezifische Werte (siteUrl, Datenbank, Mail-/SMTP-Daten, LDAP-Server, version).
* Geprüft gegen EspoCRM 10.0.8 (Settings.json / defaults/config.php).
*/
return [
// Sprache, Zeit, Zahlen
'language' => 'de_DE',
'timeZone' => 'Europe/Berlin',
'dateFormat' => 'DD.MM.YYYY',
'timeFormat' => 'HH:mm',
'weekStart' => 1, // Montag
'fiscalYearShift' => 0, // Geschäftsjahr = Kalenderjahr
'thousandSeparator' => '.',
'decimalMark' => ',',
// Währung
'currencyList' => ['EUR'],
'defaultCurrency' => 'EUR',
'baseCurrency' => 'EUR',
'currencyFormat' => 3, // "1.000,00 €" (Symbol nach dem Betrag)
'currencyDecimalPlaces' => 2,
// Adressen und Telefon
'addressFormat' => 2, // Straße / PLZ Ort / Land
'addressStateList' => [
'Baden-Württemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen', 'Hamburg', 'Hessen',
'Mecklenburg-Vorpommern', 'Niedersachsen', 'Nordrhein-Westfalen', 'Rheinland-Pfalz', 'Saarland',
'Sachsen', 'Sachsen-Anhalt', 'Schleswig-Holstein', 'Thüringen',
],
'addressCityList' => [],
'phoneNumberPreferredCountryList' => ['de'],
'phoneNumberInternational' => true, // E.164-Speicherung/-Validierung
// Listen
'recordsPerPage' => 50,
'recordsPerPageSmall' => 10,
'recordsPerPageSelect' => 20,
'recordsPerPageKanban' => 10,
// E-Mail und Export
'emailMessageMaxSize' => 25, // MB
'exportDelimiter' => ';', // CSV-Export für deutsches Excel
// Sicherheit (betrifft Espo-Passwörter: Admins, API-Benutzer; bei LDAP-Kunden die Nicht-LDAP-Konten)
'passwordStrengthLength' => 12,
'passwordStrengthBothCases' => true,
'passwordStrengthNumberCount' => 1,
'passwordStrengthSpecialCharacterCount' => 1,
'passwordGenerateLength' => 16,
'passwordRecoveryNoExposure' => true, // Passwort-Reset verrät nicht, ob eine Adresse existiert
'auth2FA' => true, // 2FA anbieten (nicht erzwingen: auth2FAForced)
// Authentifizierung: 'LDAP' nur zusammen mit den ldap*-Parametern der Kundeninstanz setzen
// (ldapHost, ldapPort, ldapSecurity, ldapUsername, ldapPassword, ldapBaseDn, ldapUserLoginFilter, ...).
// Ohne erreichbaren LDAP-Server können sich nur noch Admins anmelden. Dev-/Integrationsinstanz: 'Espo'.
// 'authenticationMethod' => 'LDAP',
];

View File

@ -0,0 +1,128 @@
# Vorlage fuer die Kunden-VM. Einziger kundenspezifischer Wert: __FQDN__.
# Gerendert von bin/espo-compose-rendern nach /srv/espo/docker-compose.yml.
# Aenderungen ausschliesslich hier im Repository, nie auf der VM.
name: espocrm
services:
espocrm-db:
image: mariadb:12.3
container_name: espocrm-db
restart: unless-stopped
stop_grace_period: 120s
security_opt: ["no-new-privileges:true"]
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MARIADB_DATABASE: espocrm
MARIADB_USER: espocrm
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_AUTO_UPGRADE: "1"
TZ: Europe/Berlin
secrets: [db_root_password, db_password]
volumes:
- /srv/espo/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 20s
start_period: 60s
timeout: 10s
retries: 3
espocrm:
image: espocrm:10.0.8
container_name: espocrm
restart: unless-stopped
stop_grace_period: 30s
security_opt: ["no-new-privileges:true"]
depends_on:
espocrm-db:
condition: service_healthy
environment:
ESPOCRM_DATABASE_PLATFORM: Mysql
ESPOCRM_DATABASE_HOST: espocrm-db
ESPOCRM_DATABASE_NAME: espocrm
ESPOCRM_DATABASE_USER: espocrm
ESPOCRM_DATABASE_PASSWORD_FILE: /run/secrets/db_password
ESPOCRM_ADMIN_USERNAME: admin
ESPOCRM_ADMIN_PASSWORD: admin
ESPOCRM_SITE_URL: https://__FQDN__
ESPOCRM_CONFIG_SITE_URL: https://__FQDN__
ESPOCRM_CONFIG_ADMIN_UPGRADE_DISABLED: "true"
ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
ESPOCRM_CONFIG_WEB_SOCKET_URL: wss://__FQDN__/wss
ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: tcp://espocrm-websocket:7777
TZ: Europe/Berlin
secrets: [db_password]
configs:
- source: recordId
target: /var/www/html/custom/Espo/Custom/Resources/metadata/app/recordId.json
volumes:
- /srv/espo/data:/var/www/html/data
- /srv/espo/custom:/var/www/html/custom
- /srv/espo/client-custom:/var/www/html/client/custom
ports:
- 8080:80
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 300s
interval: 60s
timeout: 20s
retries: 3
espocrm-daemon:
image: espocrm:10.0.8
container_name: espocrm-daemon
restart: unless-stopped
entrypoint: docker-daemon.sh
security_opt: ["no-new-privileges:true"]
environment:
TZ: Europe/Berlin
volumes_from: [espocrm]
configs:
- source: recordId
target: /var/www/html/custom/Espo/Custom/Resources/metadata/app/recordId.json
depends_on:
espocrm:
condition: service_healthy
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 60s
interval: 180s
timeout: 20s
retries: 3
espocrm-websocket:
image: espocrm:10.0.8
container_name: espocrm-websocket
restart: unless-stopped
entrypoint: docker-websocket.sh
security_opt: ["no-new-privileges:true"]
environment:
TZ: Europe/Berlin
volumes_from: [espocrm]
configs:
- source: recordId
target: /var/www/html/custom/Espo/Custom/Resources/metadata/app/recordId.json
depends_on:
espocrm:
condition: service_healthy
ports:
- 8081:8080
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 60s
interval: 180s
timeout: 20s
retries: 3
secrets:
db_root_password:
file: /srv/espo/secrets/db_root_password
db_password:
file: /srv/espo/secrets/db_password
configs:
recordId:
content: |
{"type": "uuid4", "length": 36}

View File

@ -1,8 +1,7 @@
#!/bin/bash
# ESPO-02-Docker – installiert Docker in gepinnter Version und setzt die Daemon-Grundlagen.
# ESPO-02-Docker - installiert Docker in gepinnter Version, setzt die Daemon-Grundlagen und
# beschraenkt den Zugang zu veroeffentlichten Container-Ports auf das eigene Subnetz.
# Laeuft als root ueber einen RMM-Bash-Task, idempotent.
# Firewallregeln (ufw, DOCKER-USER) kommen in einem eigenen Task, weil dafuer die Adresse
# der OPNsense als Parameter gebraucht wird.
set -u
FEHLER=0
@ -34,24 +33,64 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
docker-compose-plugin="$COMPOSE_VERSION" docker-buildx-plugin="$BUILDX_VERSION" || { echo "FEHLER: Installation"; exit 1; }
apt-mark hold docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin >/dev/null
# 3. Daemon-Grundlagen: Logrotation (der Standardtreiber rotiert nicht) und veroeffentlichte
# Ports ohne Adressangabe nur auf die Loopback-Adresse binden.
install -d -m 755 /etc/docker
# 3. Daemon-Grundlagen: Logrotation, weil der Standardtreiber nicht rotiert.
# Keine abweichende Bindeadresse: die Ports muessen fuer den Reverse Proxy erreichbar sein,
# die Beschraenkung uebernimmt Schritt 4.
install -d -m 0755 /etc/docker
cat > /etc/docker/daemon.json <<'CONF'
{
"log-driver": "local",
"log-opts": { "max-size": "20m", "max-file": "5" },
"ip": "127.0.0.1",
"live-restore": true
}
CONF
systemctl enable --now docker >/dev/null 2>&1
systemctl restart docker || { echo "FEHLER: docker startet nicht"; exit 1; }
# 4. Ergebnis zeigen und pruefen.
# 4. Firewall: Docker setzt seine Weiterleitungsregeln vor die INPUT-Kette und umgeht damit ufw.
# DOCKER-USER ist die dafuer vorgesehene Stelle. Das Subnetz ermittelt die VM selbst,
# es ist also kein kundenspezifischer Wert noetig.
command -v iptables >/dev/null || { echo "FEHLER: iptables fehlt"; exit 1; }
cat > /usr/local/sbin/espo-docker-firewall <<'CONF'
#!/bin/bash
# Laesst an veroeffentlichte Container-Ports nur das eigene Subnetz heran, verwirft den Rest.
set -u
IFACE="$(ip -4 route show default | awk '{print $5; exit}')"
SUBNETZ="$(ip -4 route show dev "$IFACE" proto kernel scope link | awk '{print $1; exit}')"
[ -n "$IFACE" ] && [ -n "$SUBNETZ" ] || { echo "FEHLER: Schnittstelle oder Subnetz nicht ermittelbar"; exit 1; }
iptables -F DOCKER-USER
iptables -A DOCKER-USER -i "$IFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A DOCKER-USER -i "$IFACE" -s "$SUBNETZ" -j RETURN
iptables -A DOCKER-USER -i "$IFACE" -j DROP
iptables -A DOCKER-USER -j RETURN
echo "DOCKER-USER: nur $SUBNETZ ueber $IFACE"
CONF
chmod 0755 /usr/local/sbin/espo-docker-firewall
cat > /etc/systemd/system/espo-docker-firewall.service <<'CONF'
[Unit]
Description=Zugang zu veroeffentlichten Docker-Ports auf das eigene Subnetz beschraenken
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/espo-docker-firewall
[Install]
WantedBy=multi-user.target
CONF
systemctl daemon-reload
systemctl enable espo-docker-firewall.service >/dev/null 2>&1
systemctl restart espo-docker-firewall.service || { echo "FEHLER: Firewallregeln nicht gesetzt"; FEHLER=1; }
# 5. Ergebnis zeigen und pruefen.
docker --version
docker compose version
docker info --format 'Logtreiber: {{.LoggingDriver}} Live-Restore: {{.LiveRestoreEnabled}}'
iptables -S DOCKER-USER
[ "$(docker info --format '{{.LoggingDriver}}')" = "local" ] || { echo "FEHLER: Logtreiber nicht gesetzt"; FEHLER=1; }
iptables -S DOCKER-USER | grep -q -- '-j DROP' || { echo "FEHLER: DOCKER-USER ohne DROP-Regel"; FEHLER=1; }
if [ "$FEHLER" -eq 0 ]; then echo "ESPO02_ERGEBNIS: OK"; exit 0; else echo "ESPO02_ERGEBNIS: FEHLER"; exit 1; fi