From 996490075a9db79d19294fdd1d5c3166b4a24c72 Mon Sep 17 00:00:00 2001 From: Gordon Zimm Date: Wed, 23 Sep 2026 14:22:59 +0200 Subject: [PATCH] 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:///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 --- README.md | 12 ++- bin/espo-compose-rendern | 57 +++++++++++++ compose/config-override.php | 67 +++++++++++++++ compose/docker-compose.vorlage.yml | 128 +++++++++++++++++++++++++++++ rmm-tasks/ESPO-02-Docker.sh | 55 +++++++++++-- 5 files changed, 309 insertions(+), 10 deletions(-) create mode 100755 bin/espo-compose-rendern create mode 100644 compose/config-override.php create mode 100644 compose/docker-compose.vorlage.yml diff --git a/README.md b/README.md index 3d8f598..2a47c66 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/bin/espo-compose-rendern b/bin/espo-compose-rendern new file mode 100755 index 0000000..9dae1bf --- /dev/null +++ b/bin/espo-compose-rendern @@ -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 (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" diff --git a/compose/config-override.php b/compose/config-override.php new file mode 100644 index 0000000..6703522 --- /dev/null +++ b/compose/config-override.php @@ -0,0 +1,67 @@ + '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', +]; diff --git a/compose/docker-compose.vorlage.yml b/compose/docker-compose.vorlage.yml new file mode 100644 index 0000000..3cdb30c --- /dev/null +++ b/compose/docker-compose.vorlage.yml @@ -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} diff --git a/rmm-tasks/ESPO-02-Docker.sh b/rmm-tasks/ESPO-02-Docker.sh index 861a1d4..cb6c0e2 100755 --- a/rmm-tasks/ESPO-02-Docker.sh +++ b/rmm-tasks/ESPO-02-Docker.sh @@ -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