Add global CAPTCHA config and EspoCRM seeder

- Introduce shared CAPTCHA settings and RECAPTCHA_ENABLED/HCAPTCHA_ENABLED flags in group_vars
- Wire reCAPTCHA/hCaptcha credentials into EspoCRM, Keycloak, Listmonk and Nextcloud
- Replace EspoCRM set_flags.php with generic seed_config.php and hook it into entrypoint/env
- Fix run_once handling in sys-ctl-cln-disc-space and minor CSS Jinja spacing issue

https://chatgpt.com/share/692a1d4f-1154-800f-a4ae-bb068aa24a53
This commit is contained in:
2025-11-28 23:08:32 +01:00
parent 4dd1769225
commit 654131ab89
20 changed files with 244 additions and 100 deletions

View File

@@ -88,3 +88,15 @@ RBAC:
GROUP:
NAME: "/roles" # Name of the group which holds the RBAC roles
CLAIM: "groups" # Name of the claim containing the RBAC groups
# You need to set both keys to enable them
CAPTCHA:
RECAPTCHA:
KEY: ""
SECRET: ""
HCAPTCHA:
KEY: ""
SECRET: ""
RECAPTCHA_ENABLED: "{{ (CAPTCHA.RECAPTCHA.KEY | length > 0) and (CAPTCHA.RECAPTCHA.SECRET | length > 0) }}"
HCAPTCHA_ENABLED: "{{ (CAPTCHA.HCAPTCHA.KEY | length > 0) and (CAPTCHA.HCAPTCHA.SECRET | length > 0) }}"

View File

@@ -15,3 +15,5 @@
system_service_tpl_exec_start: "{{ system_service_script_exec }} {{ SIZE_PERCENT_CLEANUP_DISC_SPACE }}"
system_service_tpl_exec_start_pre: '/usr/bin/python {{ PATH_SYSTEM_LOCK_SCRIPT }} {{ SYS_SERVICE_GROUP_MANIPULATION | join(" ") }} --ignore {{ SYS_SERVICE_GROUP_CLEANUP | join(" ") }} --timeout "{{ SYS_TIMEOUT_BACKUP_SERVICES }}"'
system_service_force_linear_sync: false
- include_tasks: utils/run_once.yml

View File

@@ -1,5 +1,2 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
- include_tasks: 01_core.yml
when: run_once_sys_ctl_cln_disc_space is not defined

View File

@@ -105,7 +105,7 @@ body, html[native-dark-active] {
background: linear-gradient({{ CSS_GRADIENT_ANGLE }}deg, var(--color-01-93), var(--color-01-91), var(--color-01-95), var(--color-01-93));
background-attachment: fixed;
color: var(--color-01-40);
font-family: {{design.font.type}};
font-family: {{ design.font.type }};
}
{# All links (applies to all anchor elements regardless of state) #}

View File

@@ -6,7 +6,7 @@ features:
oidc: true
central_database: true
logout: true
recaptcha: true # Required for leads formulars
recaptcha: "{{ RECAPTCHA_ENABLED | bool }}" # Required for leads formulars
server:
csp:
flags:
@@ -30,6 +30,10 @@ server:
- espo.crm.{{ PRIMARY_DOMAIN }}
email:
from_name: "Customer Relationship Management ({{ PRIMARY_DOMAIN }})"
credentials:
recaptcha:
key: "{{ CAPTCHA.RECAPTCHA.KEY }}"
secret: "{{ CAPTCHA.RECAPTCHA.SECRET }}"
docker:
services:
database:

View File

@@ -16,18 +16,14 @@ bool_norm () {
}
# --- Environment initialization ----------------------------------------------
MAINTENANCE="$(bool_norm "${ESPO_INIT_MAINTENANCE_MODE:-false}")"
CRON_DISABLED="$(bool_norm "${ESPO_INIT_CRON_DISABLED:-false}")"
USE_CACHE="$(bool_norm "${ESPO_INIT_USE_CACHE:-true}")"
MAINTENANCE="$(bool_norm "${ESPOCRM_SEED_MAINTENANCE_MODE}")"
CRON_DISABLED="$(bool_norm "${ESPOCRM_SEED_CRON_DISABLED}")"
USE_CACHE="$(bool_norm "${ESPOCRM_SEED_USE_CACHE}")"
APP_DIR="/var/www/html"
# Provided by env.j2 (fallback ensures robustness)
SET_FLAGS_SCRIPT="${ESPOCRM_SET_FLAGS_SCRIPT:-/usr/local/bin/set_flags.php}"
if [ ! -f "$SET_FLAGS_SCRIPT" ]; then
log "WARN: SET_FLAGS_SCRIPT '$SET_FLAGS_SCRIPT' not found; falling back to /usr/local/bin/set_flags.php"
SET_FLAGS_SCRIPT="/usr/local/bin/set_flags.php"
fi
SEED_CONFIG_SCRIPT="${ESPOCRM_SCRIPT_SEED}"
# --- Wait for bootstrap.php (max 60s, e.g. fresh volume) ----------------------
log "Waiting for ${APP_DIR}/bootstrap.php..."
@@ -41,10 +37,10 @@ if [ ! -f "${APP_DIR}/bootstrap.php" ]; then
exit 1
fi
# --- Apply config flags via set_flags.php ------------------------------------
log "Applying runtime flags via set_flags.php..."
if ! php "${SET_FLAGS_SCRIPT}"; then
log "ERROR: set_flags.php execution failed"
# --- Apply config flags via seed_config.php ------------------------------------
log "Applying runtime flags via seed_config.php..."
if ! php "${SEED_CONFIG_SCRIPT}"; then
log "ERROR: seed_config.php execution failed"
exit 1
fi

View File

@@ -0,0 +1,147 @@
<?php
/**
* Generic EspoCRM config seeder.
*
* Automatically scans all environment variables starting with ESPOCRM_SEED_,
* converts them into EspoCRM camelCase config keys, and writes them via ConfigWriter.
*
* Example:
* ESPOCRM_SEED_RECAPTCHA_SECRET_KEY=xyz
* becomes:
* recaptchaSecretKey => "xyz"
*/
require "/var/www/html/bootstrap.php";
$app = new \Espo\Core\Application();
$c = $app->getContainer();
$config = $c->get("config");
$writer = $c->get("injectableFactory")->create("\Espo\Core\Utils\Config\ConfigWriter");
/**
* Convert an ENV suffix like "RECAPTCHA_SECRET_KEY" to camelCase "recaptchaSecretKey".
*/
function to_camel_case(string $input): string
{
$input = strtolower($input);
$parts = explode('_', $input);
$result = array_shift($parts);
foreach ($parts as $part) {
$result .= ucfirst($part);
}
return $result;
}
/**
* Normalize booleans if the value looks boolean-like.
* Returns true/false for typical boolean strings, otherwise the original string.
*/
function cast_value(string $value)
{
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $value; // keep as string
}
/**
* Simple debug logger to STDERR.
* This keeps STDOUT clean so automation can rely on "CHANGED"/"UNCHANGED".
*/
function seed_debug(string $message): void
{
fwrite(STDERR, "[seed] " . $message . PHP_EOL);
}
// Determine debug mode from ESPOCRM_SEED_DEBUG
$debugEnv = getenv('ESPOCRM_SEED_DEBUG');
$debug = false;
if ($debugEnv !== false) {
$normalized = strtolower(trim($debugEnv));
$debug = in_array($normalized, ['1', 'true', 'yes', 'on'], true);
}
if ($debug) {
seed_debug("Seeder started, scanning ESPOCRM_SEED_* variables …");
}
$changed = false;
foreach ($_ENV as $envKey => $envValue) {
// Only process variables beginning with ESPOCRM_SEED_
if (strpos($envKey, 'ESPOCRM_SEED_') !== 0) {
continue;
}
// Extract the config part (after prefix)
$rawKey = substr($envKey, strlen('ESPOCRM_SEED_')); // e.g. "RECAPTCHA_SECRET_KEY"
if ($rawKey === '') {
continue;
}
// Convert to camelCase
$configKey = to_camel_case($rawKey);
// Normalize boolean or keep string
$value = cast_value((string) $envValue);
if ($debug) {
seed_debug(sprintf(
"ENV %s -> config key '%s' = %s",
$envKey,
$configKey,
var_export($value, true)
));
}
$current = $config->get($configKey);
if ($current !== $value) {
if ($debug) {
seed_debug(sprintf(
"Updating '%s': %s -> %s",
$configKey,
var_export($current, true),
var_export($value, true)
));
}
$writer->set($configKey, $value);
$changed = true;
} else {
if ($debug) {
seed_debug(sprintf(
"No change for '%s' (already %s)",
$configKey,
var_export($current, true)
));
}
}
}
if ($changed) {
if ($debug) {
seed_debug("Changes detected, saving configuration …");
}
$writer->save();
echo "CHANGED\n";
} else {
if ($debug) {
seed_debug("No changes detected.");
}
echo "UNCHANGED\n";
}
if ($debug) {
seed_debug("Seeder finished.");
}

View File

@@ -1,33 +0,0 @@
<?php
/**
* set_flags.php Ensure EspoCRM runtime flags are set idempotently.
*/
require "/var/www/html/bootstrap.php";
$app = new \Espo\Core\Application();
$c = $app->getContainer();
$cfg = $c->get("config");
$w = $c->get("injectableFactory")->create("\Espo\Core\Utils\Config\ConfigWriter");
// Read from ENV
$flags = [
"maintenanceMode" => in_array(strtolower(getenv("ESPO_INIT_MAINTENANCE_MODE") ?: "false"), ["1","true","yes","on"]),
"cronDisabled" => in_array(strtolower(getenv("ESPO_INIT_CRON_DISABLED") ?: "false"), ["1","true","yes","on"]),
"useCache" => in_array(strtolower(getenv("ESPO_INIT_USE_CACHE") ?: "true"), ["1","true","yes","on"])
];
$changed = false;
foreach ($flags as $k => $v) {
if ($cfg->get($k) !== $v) {
$w->set($k, $v);
$changed = true;
}
}
if ($changed) {
$w->save();
echo "CHANGED\n";
} else {
echo "UNCHANGED\n";
}

View File

@@ -13,10 +13,10 @@
- docker compose up
- docker compose build
- name: "Deploy '{{ ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS }}'"
- name: "Deploy '{{ ESPOCRM_SCRIPT_SEED_HOST_ABS }}'"
copy:
src: "{{ ESPOCRM_SET_FLAG_SCRIPT_FILE }}"
dest: "{{ ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS }}"
src: "{{ ESPOCRM_SCRIPT_SEED_FILE }}"
dest: "{{ ESPOCRM_SCRIPT_SEED_HOST_ABS }}"
notify:
- docker compose up
- docker compose build
@@ -47,7 +47,7 @@
- name: Run flag setter as root (fallback)
command: >
docker exec --user root {{ ESPOCRM_CONTAINER }}
php {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }}
php {{ ESPOCRM_SCRIPT_SEED_DOCKER }}
register: flags_result_root
changed_when: "'CHANGED' in flags_result_root.stdout"

View File

@@ -1,7 +1,7 @@
FROM "{{ ESPOCRM_IMAGE }}:{{ ESPOCRM_VERSION }}"
COPY {{ ESPOCRM_SET_FLAG_SCRIPT_HOST_REL }} {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }}
RUN chmod +x {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }}
COPY {{ ESPOCRM_SCRIPT_SEED_HOST_REL }} {{ ESPOCRM_SCRIPT_SEED_DOCKER }}
RUN chmod +x {{ ESPOCRM_SCRIPT_SEED_DOCKER }}
COPY {{ ESPOCRM_ENTRYPOINT_SCRIPT_HOST_REL }} {{ ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER }}
RUN chmod +x {{ ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER }}

View File

@@ -104,8 +104,19 @@ ESPOCRM_CONFIG_OIDC_USERNAME_CLAIM={{ OIDC.ATTRIBUTES.USERNAME }}
# ESPOCRM_CONFIG_OIDC_GROUP_CLAIM=group
{% endif %}
# --- Espo init toggles controlled at container start (used by custom entrypoint)
ESPO_INIT_MAINTENANCE_MODE=false # false = disable maintenance (recommended)
ESPO_INIT_CRON_DISABLED=false # false = enable cron jobs in config
ESPO_INIT_USE_CACHE=true # true = enable caching
ESPOCRM_SET_FLAGS_SCRIPT={{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }}
## Seed Configuration
ESPOCRM_SEED_DEBUG={{ MODE_DEBUG | lower }}
ESPOCRM_SCRIPT_SEED={{ ESPOCRM_SCRIPT_SEED_DOCKER }}
## Espo init toggles controlled at container start (used by custom entrypoint)
ESPOCRM_SEED_MAINTENANCE_MODE={{ ESPOCRM_MAINTENANCE_MODE }}
ESPOCRM_SEED_CRON_DISABLED={{ ESPOCRM_CRON_DISABLED }}
ESPOCRM_SEED_USE_CACHE={{ ESPOCRM_USE_CACHE }}
## ReCAPTCHA
ESPOCRM_SEED_RECAPTCHA_VERSION="v3"
ESPOCRM_SEED_RECAPTCHA_ENABLED="{{ ESPOCRM_RECAPTCHA_ENABLED }}"
ESPOCRM_SEED_RECAPTCHA_PUBLIC_KEY="{{ ESPOCRM_RECAPTCHA_KEY }}"
ESPOCRM_SEED_RECAPTCHA_SECRET_KEY="{{ ESPOCRM_RECAPTCHA_SECRET }}"

View File

@@ -2,6 +2,7 @@
application_id: "web-app-espocrm"
entity_name: "{{ application_id | get_entity_name }}"
# Database
database_type: "mariadb"
@@ -31,17 +32,28 @@ ESPOCRM_ENTRYPOINT_SCRIPT_HOST_ABS: "{{ [ docker_compose.directories.volumes, ES
ESPOCRM_ENTRYPOINT_SCRIPT_HOST_REL: "volumes/{{ ESPOCRM_ENTRYPOINT_SCRIPT_FILE }}"
ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER: "{{ [ '/usr/local/bin/', ESPOCRM_ENTRYPOINT_SCRIPT_FILE ] | path_join }}"
### Set Flag
ESPOCRM_SET_FLAG_SCRIPT_FILE: "set_flags.php"
ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS: "{{ [ docker_compose.directories.volumes, ESPOCRM_SET_FLAG_SCRIPT_FILE ] | path_join }}"
ESPOCRM_SET_FLAG_SCRIPT_HOST_REL: "volumes/{{ ESPOCRM_SET_FLAG_SCRIPT_FILE }}"
ESPOCRM_SET_FLAG_SCRIPT_DOCKER: "{{ [ '/usr/local/bin/', ESPOCRM_SET_FLAG_SCRIPT_FILE ] | path_join }}"
ESPOCRM_CONFIG_FILE_PRIVATE: "/var/www/html/data/config-internal.php"
ESPOCRM_URL: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
ESPOCRM_OIDC_ENABLED: "{{ applications | get_app_conf(application_id, 'features.oidc') }}"
ESPOCRM_USER: "www-data"
ESPO_INIT_MAINTENANCE_MODE: "{{ applications | get_app_conf(application_id, 'maintenance_mode') | default(false) }}"
ESPO_INIT_CRON_DISABLED: "{{ ESPO_INIT_MAINTENANCE_MODE }}" # disable cron only when in maintenance
ESPO_INIT_USE_CACHE: "{{ not ESPO_INIT_MAINTENANCE_MODE }}" # enable cache when NOT in maintenance
## Seeding
### Seeding Script Parameter
ESPOCRM_SCRIPT_SEED_FILE: "seed_config.php"
ESPOCRM_SCRIPT_SEED_HOST_ABS: "{{ [ docker_compose.directories.volumes, ESPOCRM_SCRIPT_SEED_FILE ] | path_join }}"
ESPOCRM_SCRIPT_SEED_HOST_REL: "volumes/{{ ESPOCRM_SCRIPT_SEED_FILE }}"
ESPOCRM_SCRIPT_SEED_DOCKER: "{{ [ '/usr/local/bin/', ESPOCRM_SCRIPT_SEED_FILE ] | path_join }}"
### Seeding Values
#### Maintanance
ESPOCRM_MAINTENANCE_MODE: "{{ applications | get_app_conf(application_id, 'maintenance_mode') }}"
ESPOCRM_CRON_DISABLED: "{{ ESPOCRM_MAINTENANCE_MODE }}" # disable cron only when in maintenance
ESPOCRM_USE_CACHE: "{{ not ESPOCRM_MAINTENANCE_MODE }}" # enable cache when NOT in maintenance
#### reCAPTCHA
ESPOCRM_RECAPTCHA_ENABLED: "{{ applications | get_app_conf(application_id, 'features.recaptcha') }}"
ESPOCRM_RECAPTCHA_KEY: "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.key') }}"
ESPOCRM_RECAPTCHA_SECRET: "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.secret') }}"

View File

@@ -7,7 +7,7 @@ features:
desktop: true
ldap: true
central_database: true
recaptcha: true
recaptcha: "{{ RECAPTCHA_ENABLED | bool }}"
# Doesn't make sense to activate logout page for keycloak, because the logout page
# anyhow should be included via iframe in keycloak.
@@ -45,12 +45,10 @@ docker:
pids_limit: 1024
database:
enabled: true
credentials:
recaptcha:
website_key: "" # Required if you enabled recaptcha:
secret_key: "" # Required if you enabled recaptcha:
key: "{{ CAPTCHA.RECAPTCHA.KEY }}"
secret: "{{ CAPTCHA.RECAPTCHA.SECRET }}"
accounts:
bootstrap:
username: "administrator"

View File

@@ -34,5 +34,4 @@
- name: "Load reCAPTCHA Update routines for '{{ application_id }}'"
include_tasks: update/06_recaptcha.yml
when: applications | get_app_conf(application_id, 'features.recaptcha', False)
when: KEYCLOAK_RECAPTCHA_ENABLED | bool

View File

@@ -2114,7 +2114,7 @@
"autheticatorFlow": false,
"userSetupAllowed": false
},
{%- if applications | get_app_conf(application_id, 'features.recaptcha', False) %}
{%- if KEYCLOAK_RECAPTCHA_ENABLED | bool %}
{
"authenticatorConfig": "Google reCaptcha",
"authenticator": "registration-recaptcha-action",
@@ -2204,15 +2204,15 @@
}
],
"authenticatorConfig": [
{%- if applications | get_app_conf(application_id, 'features.recaptcha', False) %}
{%- if KEYCLOAK_RECAPTCHA_ENABLED | bool %}
{
"alias": "Google reCaptcha",
"config": {
"action": "register",
"useRecaptchaNet": "false",
"recaptcha.v3": "true",
"secret.key": "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.secret_key', True) }}",
"site.key": "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.website_key', True) }}"
"secret.key": "{{ KEYCLOAK_RECAPTCHA_SECRET }}",
"site.key": "{{ KEYCLOAK_RECAPTCHA_KEY }}"
}
},
{%- endif %}

View File

@@ -77,6 +77,11 @@ KEYCLOAK_LDAP_USER_OBJECT_CLASSES: >
) | join(', ')
}}
# reCAPTCHA
KEYCLOAK_RECAPTCHA_ENABLED: "{{ applications | get_app_conf(application_id, 'features.recaptcha') }}"
KEYCLOAK_RECAPTCHA_KEY: "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.key') }}"
KEYCLOAK_RECAPTCHA_SECRET: "{{ applications | get_app_conf(application_id, 'credentials.recaptcha.secret') }}"
# Dictionaries
KEYCLOAK_DICTIONARY_REALM_RAW: "{{ lookup('template', 'import/realm.json.j2') }}"
KEYCLOAK_DICTIONARY_REALM: >-

View File

@@ -6,7 +6,7 @@ features:
central_database: true
oidc: true
logout: true
hcaptcha: true
hcaptcha: "{{ HCAPTCHA_ENABLED | bool }}"
server:
domains:
canonical:
@@ -23,9 +23,13 @@ docker:
database:
enabled: true
listmonk:
image: listmonk/listmonk
version: latest
image: listmonk/listmonk
version: latest
backup:
no_stop_required: true
name: listmonk
port: 9000
name: listmonk
port: 9000
credentials:
hcaptcha:
key: "{{ CAPTCHA.HCAPTCHA.KEY }}"
secret: "{{ CAPTCHA.HCAPTCHA.SECRET }}"

View File

@@ -3,13 +3,3 @@ credentials:
description: "Initial password for the Listmonk administrator account"
algorithm: "sha256"
validation: "^[a-f0-9]{64}$"
hcaptcha_site_key:
description: "Public site key used by Listmonk to render hCaptcha"
algorithm: "plain"
validation: "^[0-9a-zA-Z_-]{32,}$"
hcaptcha_secret:
description: "Private hCaptcha secret key for server-side verification"
algorithm: "plain"
validation: "^[0-9a-zA-Z_-]{32,}$"

View File

@@ -38,10 +38,10 @@ LISTMONK_SETTINGS:
value: 'true'
- key: "security.captcha_key"
value: '"{{ applications | get_app_conf(application_id, "credentials.hcaptcha_site_key") }}"'
value: '"{{ applications | get_app_conf(application_id, "credentials.hcaptcha.key") }}"'
- key: "security.captcha_secret"
value: '"{{ applications | get_app_conf(application_id, "credentials.hcaptcha_secret") }}"'
value: '"{{ applications | get_app_conf(application_id, "credentials.hcaptcha.secret") }}"'
# SMTP servers
- key: "smtp"

View File

@@ -127,7 +127,7 @@ features:
oidc: true
central_database: true
logout: true
hcaptcha: true
hcaptcha: "{{ HCAPTCHA_ENABLED | bool }}"
default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes)
legacy_login_mask:
enabled: False # If true, then legacy login mask is shown. Otherwise just SSO