diff --git a/roles/web-app-joomla/config/main.yml b/roles/web-app-joomla/config/main.yml index b84d8913..623be854 100644 --- a/roles/web-app-joomla/config/main.yml +++ b/roles/web-app-joomla/config/main.yml @@ -4,14 +4,21 @@ features: desktop: true central_database: true logout: true + ldap: true server: domains: canonical: - "cms.{{ PRIMARY_DOMAIN }}" + csp: + flags: + style-src: + unsafe-inline: true + script-src-elem: + unsafe-inline: true docker: services: database: - enabled: true + enabled: true joomla: image: joomla version: latest diff --git a/roles/web-app-joomla/tasks/01_install.yml b/roles/web-app-joomla/tasks/01_install.yml new file mode 100644 index 00000000..346a58a1 --- /dev/null +++ b/roles/web-app-joomla/tasks/01_install.yml @@ -0,0 +1,53 @@ +# Wait until the Joomla core is copied into the volume +- name: "Wait for Joomla files to exist" + command: + argv: [ docker, exec, "{{ JOOMLA_CONTAINER }}", test, -f, /var/www/html/index.php ] + register: joomla_files + changed_when: false + retries: 60 + delay: 2 + until: joomla_files.rc == 0 + +# (Optional) specifically wait for the CLI installer script +- name: "Check for CLI installer" + command: + argv: [ docker, exec, "{{ JOOMLA_CONTAINER }}", test, -f, /var/www/html/installation/joomla.php ] + register: has_installer + changed_when: false + failed_when: false + +# Only if not already installed (no configuration.php) +- name: "Check if Joomla is already installed" + command: + argv: [ docker, exec, "{{ JOOMLA_CONTAINER }}", test, -f, "{{ JOOMLA_CONFIG_FILE }}" ] + register: joomla_installed + changed_when: false + failed_when: false + +# Install (uses absolute path + argv) +- name: "Joomla CLI install (first run only)" + command: + argv: + - docker + - exec + - "{{ JOOMLA_CONTAINER }}" + - php + - /var/www/html/installation/joomla.php + - install + - "--db-type={{ JOOMLA_DB_CONNECTOR }}" + - "--db-host={{ database_host }}" + - "--db-user={{ database_username }}" + - "--db-pass={{ database_password }}" + - "--db-name={{ database_name }}" + - "--admin-user={{ JOOMLA_USER }}" + - "--admin-username={{ JOOMLA_USER_NAME }}" + - "--admin-password={{ JOOMLA_USER_PASSWORD }}" + - "--admin-email={{ JOOMLA_USER_EMAIL }}" + - "--no-interaction" + - "--site-name={{ JOOMLA_SITE_NAME }}" + register: j_install + changed_when: j_install.rc == 0 + failed_when: j_install.rc != 0 + when: + - joomla_installed.rc != 0 + - has_installer.rc == 0 diff --git a/roles/web-app-joomla/tasks/02_debug.yml b/roles/web-app-joomla/tasks/02_debug.yml new file mode 100644 index 00000000..f6387ab9 --- /dev/null +++ b/roles/web-app-joomla/tasks/02_debug.yml @@ -0,0 +1,45 @@ +- name: "Toggle Joomla debug flags safely (configuration.php)" + command: + argv: + - docker + - exec + - -e + - "J_MODE_DEBUG={{ MODE_DEBUG | default(false) | bool | ternary('1','0') }}" + - -e + - "J_ERR_LEVEL={{ MODE_DEBUG | default(false) | bool | ternary('maximum','default') }}" + - "{{ JOOMLA_CONTAINER }}" + - php + - -r + - | + $f = '{{ JOOMLA_CONFIG_FILE }}'; + if (!file_exists($f)) { fwrite(STDERR, "configuration.php missing\n"); exit(1); } + $c = file_get_contents($f); + $changed = 0; + + $debug = getenv('J_MODE_DEBUG') === '1'; + $err = getenv('J_ERR_LEVEL') ?: 'default'; + + // Clean up previously broken lines + $c = preg_replace('/^\s*public\s+1\s*=.*?;$/m', '', $c, -1, $nBad1); $changed += $nBad1; + $c = preg_replace('/^\s*public\s*=\s*maximum;$/m', '', $c, -1, $nBad2); $changed += $nBad2; + + // Ensure: public $debug = true|false; + $lineDebug = "public \$debug = " . ($debug ? 'true' : 'false') . ";"; + if (preg_match('/public\s*\$debug\s*=\s*[^;]*;/', $c)) { + $c = preg_replace('/public\s*\$debug\s*=\s*[^;]*;/', $lineDebug, $c, 1, $n); $changed += $n; + } else { + $c = preg_replace("/\n\}\s*$/", "\n\t".$lineDebug."\n}\n", $c, 1, $n); $changed += $n; + } + + // Ensure: public $error_reporting = 'maximum'|'default'; + $lineErr = "public \$error_reporting = '" . str_replace("'", "\\'", $err) . "';"; + if (preg_match('/public\s*\$error_reporting\s*=\s*[^;]*;/', $c)) { + $c = preg_replace('/public\s*\$error_reporting\s*=\s*[^;]*;/', $lineErr, $c, 1, $n); $changed += $n; + } else { + $c = preg_replace("/\n\}\s*$/", "\n\t".$lineErr."\n}\n", $c, 1, $n); $changed += $n; + } + + if ($changed) { file_put_contents($f, $c); echo "changed"; } else { echo "ok"; } + register: j_cfg_debug + changed_when: (j_cfg_debug.stdout | trim) == "changed" + failed_when: j_cfg_debug.rc != 0 diff --git a/roles/web-app-joomla/tasks/03_patch.yml b/roles/web-app-joomla/tasks/03_patch.yml new file mode 100644 index 00000000..83c73a8d --- /dev/null +++ b/roles/web-app-joomla/tasks/03_patch.yml @@ -0,0 +1,52 @@ +- name: "Ensure configuration.php DB settings match inventory" + command: + argv: + - docker + - exec + - -e + - J_DBTYPE={{ JOOMLA_DB_CONNECTOR }} + - -e + - J_DBHOST={{ database_host }}:{{ database_port }} + - -e + - J_DBUSER={{ database_username }} + - -e + - J_DBPASS={{ database_password }} + - -e + - J_DBNAME={{ database_name }} + - "{{ JOOMLA_CONTAINER }}" + - php + - -r + - | + $f = '{{ JOOMLA_CONFIG_FILE }}'; + if (!file_exists($f)) { exit(0); } + $c = file_get_contents($f); + $changed = 0; + + $map = [ + 'dbtype' => getenv('J_DBTYPE'), + 'host' => getenv('J_DBHOST'), + 'user' => getenv('J_DBUSER'), + 'password' => getenv('J_DBPASS'), + 'db' => getenv('J_DBNAME'), + ]; + + foreach ($map as $k => $v) { + // Escape single quotes for safe embedding into the PHP source string + $vEsc = str_replace("'", "\\'", $v); + + // Match current value in config: public $key = '...'; + if (preg_match("/public \\$".$k."\\s*=\\s*'([^']*)';/", $c, $m) && $m[1] !== $v) { + $c = preg_replace( + "/public \\$".$k."\\s*=\\s*'[^']*';/", + "public $".$k." = '".$vEsc."';", + $c + ); + $changed = 1; + } + } + + if ($changed) { file_put_contents($f, $c); echo "changed"; } else { echo "ok"; } + register: cfg_patch + changed_when: cfg_patch.stdout == "changed" + failed_when: cfg_patch.rc != 0 + when: joomla_installed.rc == 0 diff --git a/roles/web-app-joomla/tasks/04_ldap.yml b/roles/web-app-joomla/tasks/04_ldap.yml new file mode 100644 index 00000000..6b2c2d0d --- /dev/null +++ b/roles/web-app-joomla/tasks/04_ldap.yml @@ -0,0 +1,9 @@ +- name: "Configure LDAP plugin params via helper" + command: > + docker exec {{ JOOMLA_CONTAINER }} + php cli/cli-ldap.php + register: ldap_conf + changed_when: "'configured' in ldap_conf.stdout | lower" + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + when: JOOMLA_LDAP_ENABLED | bool \ No newline at end of file diff --git a/roles/web-app-joomla/tasks/05_assert.yml b/roles/web-app-joomla/tasks/05_assert.yml new file mode 100644 index 00000000..bae42dd8 --- /dev/null +++ b/roles/web-app-joomla/tasks/05_assert.yml @@ -0,0 +1,5 @@ +- name: "PHP lint configuration.php" + command: + argv: [ docker, exec, "{{ JOOMLA_CONTAINER }}", php, "-l", "{{ JOOMLA_CONFIG_FILE }}" ] + changed_when: false + when: MODE_ASSERT | bool diff --git a/roles/web-app-joomla/tasks/main.yml b/roles/web-app-joomla/tasks/main.yml index 789d51a6..e6191fe8 100644 --- a/roles/web-app-joomla/tasks/main.yml +++ b/roles/web-app-joomla/tasks/main.yml @@ -1,7 +1,34 @@ --- -- name: "load docker, db and proxy for {{ application_id }}" - include_role: - name: cmp-db-docker-proxy - loop: "{{ domains }}" +- name: "Include role srv-domain-provision for {{ application_id }}" + include_role: + name: srv-domain-provision + loop: "{{ JOOMLA_DOMAINS }}" loop_control: - loop_var: domain \ No newline at end of file + loop_var: domain + vars: + http_port: "{{ ports.localhost.http[application_id] }}" + +- name: "load docker and db for {{ application_id }}" + include_role: + name: cmp-db-docker + vars: + docker_compose_flush_handlers: false + +- name: "Render LDAP CLI helper" + template: + src: cli-ldap.php.j2 + dest: "{{ JOOMLA_LDAP_CONF_FILE }}" + mode: "0644" + when: JOOMLA_LDAP_ENABLED | bool + +- name: "flush docker compose handlers" + meta: flush_handlers + +- name: Include install routines + include_tasks: "{{ item }}" + loop: + - 01_install.yml + - 02_debug.yml + - 03_patch.yml + - 04_ldap.yml + - 05_assert.yml diff --git a/roles/web-app-joomla/templates/Dockerfile.j2 b/roles/web-app-joomla/templates/Dockerfile.j2 new file mode 100644 index 00000000..0770d5d4 --- /dev/null +++ b/roles/web-app-joomla/templates/Dockerfile.j2 @@ -0,0 +1,15 @@ +FROM {{ JOOMLA_IMAGE }}:{{ JOOMLA_VERSION }} +{% if JOOMLA_LDAP_ENABLED %} +ENV DEBIAN_FRONTEND=noninteractive +RUN set -eux; \ + apt-get update; \ + PHPV="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')" || PHPV=""; \ + apt-get install -y --no-install-recommends "php${PHPV}-ldap" \ + || ( \ + apt-get install -y --no-install-recommends libldap2-dev libsasl2-dev pkg-config; \ + docker-php-ext-configure ldap --with-ldap=/usr --with-ldap-sasl=/usr \ + || docker-php-ext-configure ldap --with-ldap=/usr; \ + docker-php-ext-install -j"$(nproc)" ldap \ + ); \ + rm -rf /var/lib/apt/lists/* +{% endif %} diff --git a/roles/web-app-joomla/templates/cli-ldap.php.j2 b/roles/web-app-joomla/templates/cli-ldap.php.j2 new file mode 100644 index 00000000..5a21d8e8 --- /dev/null +++ b/roles/web-app-joomla/templates/cli-ldap.php.j2 @@ -0,0 +1,54 @@ +getQuery(true) + ->select('*') + ->from($dbo->quoteName('#__extensions')) + ->where($dbo->quoteName('type') . ' = ' . $dbo->quote('plugin')) + ->where($dbo->quoteName('folder') . ' = ' . $dbo->quote('authentication')) + ->where($dbo->quoteName('element') . ' = ' . $dbo->quote('ldap')); +$dbo->setQuery($query); +$ext = $dbo->loadObject(); + +if (!$ext) { fwrite(STDERR, "LDAP plugin not found.\n"); exit(2); } + +// Merge desired params +$desired = [ + "host" => getenv('JOOMLA_LDAP_HOST'), + "port" => (int) getenv('JOOMLA_LDAP_PORT'), + "basedn" => getenv('JOOMLA_LDAP_BASE_DN'), + "userbasedn" => getenv('JOOMLA_LDAP_USER_TREE_DN'), + "groupbasedn" => getenv('JOOMLA_LDAP_GROUP_TREE_DN'), + "authmethod" => getenv('JOOMLA_LDAP_AUTH_METHOD'), // "bind" or "search" + "searchstring" => getenv('JOOMLA_LDAP_USER_SEARCH_STRING'), + "username" => getenv('JOOMLA_LDAP_BIND_DN'), + "password" => getenv('JOOMLA_LDAP_BIND_PASSWORD'), + "uid" => getenv('JOOMLA_LDAP_UID_ATTR'), + "email" => getenv('JOOMLA_LDAP_EMAIL_ATTR'), + "fullname" => getenv('JOOMLA_LDAP_NAME_ATTR'), + "starttls" => (bool) getenv('JOOMLA_LDAP_USE_STARTTLS'), + "ignore_reqcert" => (bool) getenv('JOOMLA_LDAP_IGNORE_CERT'), + "mapfullname" => (bool) getenv('JOOMLA_LDAP_MAP_FULLNAME'), + "mapemail" => (bool) getenv('JOOMLA_LDAP_MAP_EMAIL'), +]; + +$current = json_decode($ext->params ?: "{}", true) ?: []; +$merged = array_replace($current, array_filter($desired, fn($v) => $v !== null && $v !== '')); + +$ext->params = json_encode($merged, JSON_UNESCAPED_SLASHES); +$ext->enabled = {{ JOOMLA_LDAP_ENABLED | ternary(1, 0) }}; + +$dbo->updateObject('#__extensions', $ext, 'extension_id'); + +echo "LDAP plugin enabled=". $ext->enabled . " and configured.\n"; diff --git a/roles/web-app-joomla/templates/docker-compose.yml.j2 b/roles/web-app-joomla/templates/docker-compose.yml.j2 index 05f9f939..2f9cf8f9 100644 --- a/roles/web-app-joomla/templates/docker-compose.yml.j2 +++ b/roles/web-app-joomla/templates/docker-compose.yml.j2 @@ -1,10 +1,17 @@ {% include 'roles/docker-compose/templates/base.yml.j2' %} application: - image: "{{ JOOMLA_IMAGE }}:{{ JOOMLA_VERSION }}" - container_name: "{{ JOOMLA_CONTAINER }}" + build: + context: {{ docker_compose.directories.instance }} + dockerfile: Dockerfile + image: "{{ JOOMLA_CUSTOM_IMAGE }}" + container_name: {{ JOOMLA_CONTAINER }} + pull_policy: never {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - data:/var/www/html +{% if JOOMLA_LDAP_ENABLED %} + - {{ JOOMLA_LDAP_CONF_FILE }}:/var/www/html/cli/cli-ldap.php:ro +{% endif %} ports: - "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}" {% include 'roles/docker-container/templates/healthcheck/curl.yml.j2' %} diff --git a/roles/web-app-joomla/templates/env.j2 b/roles/web-app-joomla/templates/env.j2 index a36ba0cc..3b90f152 100644 --- a/roles/web-app-joomla/templates/env.j2 +++ b/roles/web-app-joomla/templates/env.j2 @@ -1,4 +1,34 @@ +JOOMLA_SITE_NAME={{ JOOMLA_SITE_NAME }} +JOOMLA_ADMIN_USER={{ JOOMLA_USER }} +JOOMLA_ADMIN_USERNAME={{ JOOMLA_USER_NAME }} +JOOMLA_ADMIN_PASSWORD={{ JOOMLA_USER_PASSWORD }} +JOOMLA_ADMIN_EMAIL={{ JOOMLA_USER_EMAIL }} + +{% if database_type == 'mariadb' %} +# Database JOOMLA_DB_HOST="{{ database_host }}:{{ database_port }}" JOOMLA_DB_USER="{{ database_username }}" JOOMLA_DB_PASSWORD="{{ database_password }}" -JOOMLA_DB_NAME="{{ database_name }}" \ No newline at end of file +JOOMLA_DB_NAME="{{ database_name }}" +JOOMLA_DB_TYPE="{{ JOOMLA_DB_CONNECTOR }}" +{% endif %} + +{% if JOOMLA_LDAP_ENABLED %} +# LDAP +JOOMLA_LDAP_HOST="{{ JOOMLA_LDAP_HOST }}" +JOOMLA_LDAP_PORT="{{ JOOMLA_LDAP_PORT }}" +JOOMLA_LDAP_BASE_DN="{{ JOOMLA_LDAP_BASE_DN }}" +JOOMLA_LDAP_USER_TREE_DN="{{ JOOMLA_LDAP_USER_TREE_DN }}" +JOOMLA_LDAP_GROUP_TREE_DN="{{ JOOMLA_LDAP_GROUP_TREE_DN }}" +JOOMLA_LDAP_UID_ATTR="{{ JOOMLA_LDAP_UID_ATTR }}" +JOOMLA_LDAP_EMAIL_ATTR="{{ JOOMLA_LDAP_EMAIL_ATTR }}" +JOOMLA_LDAP_NAME_ATTR="{{ JOOMLA_LDAP_NAME_ATTR }}" +JOOMLA_LDAP_BIND_DN="{{ JOOMLA_LDAP_BIND_DN }}" +JOOMLA_LDAP_BIND_PASSWORD="{{ JOOMLA_LDAP_BIND_PASSWORD }}" +JOOMLA_LDAP_USE_STARTTLS="{{ JOOMLA_LDAP_USE_STARTTLS | ternary('1','') }}" +JOOMLA_LDAP_IGNORE_CERT="{{ JOOMLA_LDAP_IGNORE_CERT | ternary('1','') }}" +JOOMLA_LDAP_MAP_FULLNAME="{{ JOOMLA_LDAP_MAP_FULLNAME | ternary('1','') }}" +JOOMLA_LDAP_MAP_EMAIL="{{ JOOMLA_LDAP_MAP_EMAIL | ternary('1','') }}" +JOOMLA_LDAP_AUTH_METHOD="{{ JOOMLA_LDAP_AUTH_METHOD }}" +JOOMLA_LDAP_USER_SEARCH_STRING="{{ JOOMLA_LDAP_USER_SEARCH_STRING }}" +{% endif %} \ No newline at end of file diff --git a/roles/web-app-joomla/vars/main.yml b/roles/web-app-joomla/vars/main.yml index fecd0c05..2d61f59e 100644 --- a/roles/web-app-joomla/vars/main.yml +++ b/roles/web-app-joomla/vars/main.yml @@ -1,10 +1,41 @@ # General -application_id: "web-app-joomla" -database_type: "postgres" -container_port: 80 +application_id: "web-app-joomla" +database_type: "mariadb" +container_port: 80 # Joomla -JOOMLA_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.version') }}" -JOOMLA_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.image') }}" -JOOMLA_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.name') }}" -JOOMLA_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" \ No newline at end of file +JOOMLA_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.version') }}" +JOOMLA_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.image') }}" +JOOMLA_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.joomla.name') }}" +JOOMLA_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" +JOOMLA_CUSTOM_IMAGE: "{{ JOOMLA_IMAGE }}_custom" +JOOMLA_DOMAINS: "{{ applications | get_app_conf(application_id, 'server.domains.canonical') }}" +JOOMLA_SITE_NAME: "{{ SOFTWARE_NAME }} Joomla - CMS" +JOOMLA_DB_CONNECTOR: "{{ 'pgsql' if database_type == 'postgres' else 'mysqli' }}" +JOOMLA_CONFIG_FILE: "/var/www/html/configuration.php" + +# User +JOOMLA_USER_NAME: "{{ users.administrator.username }}" +JOOMLA_USER: "{{ JOOMLA_USER_NAME | capitalize }}" +JOOMLA_USER_PASSWORD: "{{ users.administrator.password }}" +JOOMLA_USER_EMAIL: "{{ users.administrator.email }}" + +# LDAP +JOOMLA_LDAP_CONF_FILE: "{{ [ docker_compose.directories.volumes, 'cli-ldap.php' ] | path_join }}" +JOOMLA_LDAP_ENABLED: "{{ applications | get_app_conf(application_id, 'features.ldap') }}" +JOOMLA_LDAP_HOST: "{{ LDAP.SERVER.DOMAIN }}" +JOOMLA_LDAP_PORT: "{{ LDAP.SERVER.PORT }}" +JOOMLA_LDAP_BASE_DN: "{{ LDAP.DN.ROOT }}" +JOOMLA_LDAP_USER_TREE_DN: "{{ LDAP.DN.OU.USERS }}" +JOOMLA_LDAP_GROUP_TREE_DN: "{{ LDAP.DN.OU.GROUPS }}" +JOOMLA_LDAP_UID_ATTR: "{{ LDAP.USER.ATTRIBUTES.ID }}" # e.g. uid +JOOMLA_LDAP_EMAIL_ATTR: "{{ LDAP.USER.ATTRIBUTES.MAIL }}" +JOOMLA_LDAP_NAME_ATTR: "{{ LDAP.USER.ATTRIBUTES.FULLNAME }}" +JOOMLA_LDAP_BIND_DN: "{{ LDAP.DN.ADMINISTRATOR.DATA }}" +JOOMLA_LDAP_BIND_PASSWORD: "{{ LDAP.BIND_CREDENTIAL }}" +JOOMLA_LDAP_USE_STARTTLS: false +JOOMLA_LDAP_IGNORE_CERT: true +JOOMLA_LDAP_MAP_FULLNAME: true +JOOMLA_LDAP_MAP_EMAIL: true +JOOMLA_LDAP_AUTH_METHOD: "search" # "bind" or "search" +JOOMLA_LDAP_USER_SEARCH_STRING: "{{ JOOMLA_LDAP_UID_ATTR }}=[username],{{ JOOMLA_LDAP_USER_TREE_DN }}"