diff --git a/README.rst b/README.rst index d7ba590..4fc9ec1 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,9 @@ and on top of that: **Security note**: Updates to EspoCRM may require supervision so they **ARE NOT** configured to install automatically. See `EspoCRM - documentation`_ for upgrading. + documentation`_ for upgrading. The supported command-line updater is + ``sudo -u www-data php /var/www/espocrm/command.php upgrade``. Back up + the appliance before applying an upgrade. - Websocket_ preconfigured and enabled. @@ -40,4 +42,3 @@ Credentials *(passwords set at first boot)* .. _EspoCRM documentation: https://www.espocrm.com/documentation/administration/upgrading/ .. _Websocket: https://docs.espocrm.com/administration/websocket/ .. _Adminer: https://www.adminer.org - diff --git a/changelog b/changelog index bfe4ff7..cd52fdd 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,20 @@ +turnkey-espocrm-19.0 (1) turnkey; urgency=low + + * Install EspoCRM 10.0.6 from the official upstream release archive and + verify its published SHA-256 digest during the build. + + * Update the appliance for Debian 13/Trixie, PHP 8.4 and EspoCRM's current + bcrypt password format. + + * Set the firstboot site URL through EspoCRM's supported configuration + command. + + * Restrict the EspoCRM database account to the EspoCRM database. + + * Upgrade the base distribution to Debian 13/Trixie. + + -- Jeremy Davis Tue, 25 Aug 2026 00:00:00 +0000 + turnkey-espocrm-18.0 (1) turnkey; urgency=low * Install latest upstream version of EspoCRM: v8.0.6. diff --git a/conf.d/downloads b/conf.d/downloads index 9fc5e33..25d5da9 100755 --- a/conf.d/downloads +++ b/conf.d/downloads @@ -1,11 +1,16 @@ -#!/bin/bash -ex +#!/bin/bash +set -euo pipefail -dl() { - [ "$FAB_HTTP_PROXY" ] && PROXY="--proxy $FAB_HTTP_PROXY" - cd $2; curl -L -f -O $PROXY $1; cd - -} +readonly VERSION=10.0.6 +readonly ARCHIVE="EspoCRM-${VERSION}.zip" +readonly URL="https://github.com/espocrm/espocrm/releases/download/${VERSION}/${ARCHIVE}" +readonly SHA256=88bcb177dbe38b79ec3c13d491d78b727dc14fe18fc91deea1bfa72b313f7fb4 -VERSION=$(gh_releases espocrm/espocrm | grep -vi 'beta' | sort -V | tail -1) -URL="https://github.com/espocrm/espocrm/releases/download/$VERSION/EspoCRM-$VERSION.zip" +proxy_args=() +if [[ -n ${FAB_HTTP_PROXY:-} ]]; then + proxy_args=(--proxy "$FAB_HTTP_PROXY") +fi -dl $URL /usr/local/src +curl --fail --location --show-error "${proxy_args[@]}" \ + --output "/usr/local/src/${ARCHIVE}" "$URL" +printf '%s %s\n' "$SHA256" "/usr/local/src/${ARCHIVE}" | sha256sum --check --strict diff --git a/conf.d/main b/conf.d/main index e5348b9..7299eee 100755 --- a/conf.d/main +++ b/conf.d/main @@ -1,4 +1,5 @@ -#!/bin/sh -ex +#!/bin/bash +set -euo pipefail DB_NAME=espocrm DB_USER=espocrm @@ -12,13 +13,13 @@ WEBROOT=/var/www/espocrm # unpack and set required permissions -unzip $SRC/EspoCRM-*.zip -d $SRC -rm $SRC/EspoCRM-*.zip -mv $SRC/EspoCRM-* $WEBROOT -chown -R www-data:www-data $WEBROOT +unzip "$SRC"/EspoCRM-*.zip -d "$SRC" +rm "$SRC"/EspoCRM-*.zip +mv "$SRC"/EspoCRM-* "$WEBROOT" +chown -R www-data:www-data "$WEBROOT" # php.ini -CONF=/etc/php/8.2/apache2/php.ini +CONF=/etc/php/8.4/apache2/php.ini sed -i "s|^memory_limit.*|memory_limit = 256M|" $CONF sed -i "s|^upload_max_filesize.*|upload_max_filesize = 50M|" $CONF sed -i "s|^post_max_size.*|post_max_size = 50M|" $CONF @@ -29,6 +30,7 @@ sed -i "s|^max_execution_time.*|max_execution_time = 180|" $CONF a2dissite 000-default a2enmod headers a2ensite espocrm +a2enmod ssl a2enmod rewrite a2enmod proxy a2enmod proxy_wstunnel @@ -38,30 +40,29 @@ service mysql start service apache2 start -MYSQL_BATCH="mysql --batch" +MYSQL_BATCH=(mysql --batch) -mysqladmin create $DB_NAME - -$MYSQL_BATCH --execute "CREATE USER 'espocrm'@'localhost' IDENTIFIED BY '$DB_PASS';" -$MYSQL_BATCH --execute "GRANT ALL PRIVILEGES ON * . * TO 'espocrm'@'localhost';" +"${MYSQL_BATCH[@]}" --execute "CREATE DATABASE \`${DB_NAME}\`;" +"${MYSQL_BATCH[@]}" --execute "CREATE USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';" +"${MYSQL_BATCH[@]}" --execute "GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'localhost';" # curl URL="http://127.0.0.1/install/" CURL="curl --request POST -c /tmp/cookie -b /tmp/cookie" -$CURL $URL --data "user-lang=en_US&action=step1" -$CURL $URL --data "license-agree=1&action=step2" -$CURL ${URL}index.php --data "hostName=localhost&dbName=$DB_NAME&dbUserName=$DB_USER&dbUserPass=$DB_PASS&action=settingsTest" -$CURL $URL --data "host-name=localhost&db-name=$DB_NAME&db-user-name=$DB_USER&db-user-password=$DB_PASS&action=setupConfirmation" -$CURL ${URL}index.php --data "action=saveSettings" -$CURL ${URL}index.php --data "action=buildDatabase" -$CURL $URL --data "action=step3" -$CURL ${URL}index.php --data "name=$ADMIN_NAME&pass=$ADMIN_PASS&confPass=$ADMIN_PASS&user-name=$ADMIN_NAME&user-pass=$ADMIN_PASS&action=createUser" -$CURL $URL --data "user-name=$ADMIN_NAME&user-pass=$ADMIN_PASS&user-confirm-pass=$ADMIN_PASS&action=step4" -$CURL ${URL}index.php --data "dateFormat=MM%2FDD%2FYYYY&timeFormat=HH%3Amm&timeZone=UTC&weekStart=0&defaultCurrency=USD&thousandSeparator=%2C&decimalMark=.&language=en_US&action=savePreferences" -$CURL ${URL} --data "dateFormat=MM%2FDD%2FYYYY&timeFormat=HH%3Amm&timeZone=UTC&weekStart=0&defaultCurrency=USD&thousandSeparator=%2C&decimalMark=.&language=en_US&action=step5" -$CURL ${URL}index.php --data "smtpServer=&smtpPort=25&smtpAuth=false&smtpSecurity=TLS&smtpUsername=&smtpPassword=&outboundEmailFromName=&outboundEmailFromAddress=&outboundEmailIsShared=false&action=saveEmailSettings" -$CURL ${URL} --data "outboundEmailFromName=&outboundEmailFromAddress=&outboundEmailIsShared=on&smtpServer=&smtpPort=25&smtpSecurity=TLS&smtpUsername=&smtpPassword=&action=finish" +$CURL "$URL" --data "user-lang=en_US&action=step1" +$CURL "$URL" --data "license-agree=1&action=step2" +$CURL "${URL}index.php" --data "hostName=localhost&dbName=$DB_NAME&dbUserName=$DB_USER&dbUserPass=$DB_PASS&action=settingsTest" +$CURL "$URL" --data "host-name=localhost&db-name=$DB_NAME&db-user-name=$DB_USER&db-user-password=$DB_PASS&action=setupConfirmation" +$CURL "${URL}index.php" --data "action=saveSettings" +$CURL "${URL}index.php" --data "action=buildDatabase" +$CURL "$URL" --data "action=step3" +$CURL "${URL}index.php" --data "name=$ADMIN_NAME&pass=$ADMIN_PASS&confPass=$ADMIN_PASS&user-name=$ADMIN_NAME&user-pass=$ADMIN_PASS&action=createUser" +$CURL "$URL" --data "user-name=$ADMIN_NAME&user-pass=$ADMIN_PASS&user-confirm-pass=$ADMIN_PASS&action=step4" +$CURL "${URL}index.php" --data "dateFormat=MM%2FDD%2FYYYY&timeFormat=HH%3Amm&timeZone=UTC&weekStart=0&defaultCurrency=USD&thousandSeparator=%2C&decimalMark=.&language=en_US&action=savePreferences" +$CURL "$URL" --data "dateFormat=MM%2FDD%2FYYYY&timeFormat=HH%3Amm&timeZone=UTC&weekStart=0&defaultCurrency=USD&thousandSeparator=%2C&decimalMark=.&language=en_US&action=step5" +$CURL "${URL}index.php" --data "smtpServer=&smtpPort=25&smtpAuth=false&smtpSecurity=TLS&smtpUsername=&smtpPassword=&outboundEmailFromName=&outboundEmailFromAddress=&outboundEmailIsShared=false&action=saveEmailSettings" +$CURL "$URL" --data "outboundEmailFromName=&outboundEmailFromAddress=&outboundEmailIsShared=on&smtpServer=&smtpPort=25&smtpSecurity=TLS&smtpUsername=&smtpPassword=&action=finish" rm -f /tmp/cookie @@ -70,5 +71,5 @@ service apache2 stop service mysql stop # enable websocket -sed -i "\|useWebSocket|s|>.*|> true,|" $WEBROOT/data/config.php +sed -i "\|useWebSocket|s|>.*|> true,|" "$WEBROOT/data/config.php" systemctl enable espocrm-websocket.service diff --git a/docs/v19.0-testing.md b/docs/v19.0-testing.md new file mode 100644 index 0000000..4ca5ad3 --- /dev/null +++ b/docs/v19.0-testing.md @@ -0,0 +1,77 @@ +# EspoCRM 19.0 testing + +## Dependency source decision + +EspoCRM is not packaged by Debian Trixie. The appliance installs EspoCRM +10.0.6 from the official GitHub release archive. The build pins the version and +verifies GitHub's published SHA-256 digest before unpacking it. PHP 8.4, +MariaDB, Apache, the required PHP extensions, Postfix, Adminer and Webmin come +from the TurnKey LAMP plan and Debian Trixie packages. + +The obsolete v18 `php-imap` plan entry is removed. Trixie does not provide that +extension, and EspoCRM 10.0.6 does not declare it as a system requirement or +use PHP's removed IMAP functions. + +EspoCRM supports supervised upgrades with: + +```sh +cd /var/www/espocrm +sudo -u www-data php command.php upgrade +``` + +Back up the appliance before upgrading. The acceptance test queries official +GitHub release metadata without changing the installed application. + +## README primary-flow crosswalk + +| README claim | Acceptance evidence | +| --- | --- | +| EspoCRM installed at `/var/www/espocrm` | The test reads the installed version with EspoCRM's command runner and requires 10.0.6. | +| HTTPS application | The test requests the application through Apache on HTTPS and checks that the firstboot domain is stored as the site URL. | +| Administrator credentials set at first boot | The test authenticates `admin` using the firstboot password and obtains an EspoCRM API token. | +| CRM lead workflow | The authenticated test creates a lead, reads it back through the REST API, and confirms the record in MariaDB. | +| WebSocket integration | The test requires the EspoCRM WebSocket service to be active. | +| Scheduled jobs | The test invokes the documented cron entry point as `www-data` and requires successful completion. | +| MariaDB and Adminer | The test pings MariaDB with the firstboot root password, verifies lead persistence, and requires the Adminer package. | +| Postfix and Webmin modules | The test requires Postfix and the Apache, MariaDB and Postfix Webmin modules in the installed package database. | +| Supervised upstream updates | The test queries the official stable release channel, records the available version, and confirms the installed version is unchanged. | +| Standard TurnKey Core features | Covered by the established Core 19 baseline. | + +## Validation commands + +Static validation: + +```sh +bash -n conf.d/downloads conf.d/main tests/v19.sh \ + overlay/usr/lib/inithooks/firstboot.d/20regen-espocrm-secrets \ + overlay/usr/lib/inithooks/firstboot.d/40espocrm +python3 -m py_compile overlay/usr/lib/inithooks/bin/espocrm.py +git diff --check +``` + +Full appliance validation: + +```sh +/sandboxed-git/turnkey/tools/test-v19-appliance espocrm \ + --source /home/agent/.local/worktrees/turnkey-apps/espocrm/wish-espocrm-v19-trixie +``` + +The full run builds the Trixie root filesystem, completes normal boot and +firstboot configuration, and then executes `tests/v19.sh` inside the running +Docker appliance. + +## Inherited Core 19 evidence + +The accepted Core 19 Docker baseline verifies normal init, multi-user +operation, SSH, cron, Debian Trixie identity, updater channels and signed APT +metadata. This appliance test focuses on the additional EspoCRM contract. The +Core or TKLDev ISO validation covers the shared ISO path, so this migration does +not repeat an ISO boot. + +## Deferred minor issues + +- Outbound email delivery is not exercised because it requires an external + recipient and is not part of the normal CRM data-entry flow. The test checks + that Postfix and its Webmin integration are installed. +- The test validates the WebSocket daemon and ZMQ integration but does not run + a browser notification exchange. diff --git a/overlay/usr/lib/inithooks/bin/espocrm.py b/overlay/usr/lib/inithooks/bin/espocrm.py index 9303e0c..5b7af57 100755 --- a/overlay/usr/lib/inithooks/bin/espocrm.py +++ b/overlay/usr/lib/inithooks/bin/espocrm.py @@ -9,9 +9,7 @@ import sys import getopt -import hashlib -import crypt -import re +import subprocess from libinithooks import inithooks_cache from libinithooks.dialog_wrapper import Dialog @@ -65,25 +63,39 @@ def main(): inithooks_cache.write('APP_DOMAIN', domain) - conf = "/var/www/espocrm/data/config-internal.php" - - lines = [] - with open(conf, 'r') as fob: - for line in fob: - match = re.search("'passwordSalt' => '([^']*)',", line) - if match != None: - normSalt = ('$6$%s$' % match.group(1)) - hashed = crypt.crypt(hashlib.md5(password.encode('utf8')).hexdigest(), normSalt).replace(normSalt, '') - - m = MySQL() - m.execute('UPDATE espocrm.user SET password=%s WHERE user_name=\"admin\"', (hashed)) - if 'siteUrl' in line: - line = re.sub("=> '([^']*)'", f"=> 'https://{domain}'", line) - - lines.append(line) - - with open(conf, 'w') as fob: - fob.writelines(lines) + subprocess.run( + [ + 'runuser', + '-u', + 'www-data', + '--', + 'php', + 'command.php', + 'config:set', + 'siteUrl', + f'https://{domain}', + ], + cwd='/var/www/espocrm', + check=True, + ) + + hashed = subprocess.run( + [ + 'php', + '-r', + 'echo password_hash(stream_get_contents(STDIN), PASSWORD_BCRYPT);', + ], + input=password, + text=True, + check=True, + capture_output=True, + ).stdout + + m = MySQL() + m.execute( + 'UPDATE espocrm.user SET password=%s WHERE user_name="admin"', + (hashed,), + ) if __name__ == "__main__": main() diff --git a/plan/main b/plan/main index b445dfb..bbbdea6 100644 --- a/plan/main +++ b/plan/main @@ -4,11 +4,12 @@ php-gd php-cli php-curl -php-imap php-mbstring php-zip php-xml php-zmq +php-bcmath +php-exif unzip uuid-runtime diff --git a/tests/v19.sh b/tests/v19.sh new file mode 100755 index 0000000..f1effca --- /dev/null +++ b/tests/v19.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -Eeuo pipefail +umask 077 + +result=${TKL_TEST_RESULT:?TKL_TEST_RESULT is required} +app_password=${TKL_TEST_APP_PASS:?TKL_TEST_APP_PASS is required} +db_password=${TKL_TEST_DB_PASS:?TKL_TEST_DB_PASS is required} +base=https://localhost +api=$base/api/v1 +auth_file=/tmp/tkl-espocrm-auth.$$ +home_file=/tmp/tkl-espocrm-home.$$ +lead_file=/tmp/tkl-espocrm-lead.$$ +release_file=/tmp/tkl-espocrm-release.$$ +latest_file=/tmp/tkl-espocrm-latest.$$ + +cleanup() { + rm -f -- "$auth_file" "$home_file" "$lead_file" "$release_file" \ + "$latest_file" +} +trap cleanup EXIT + +resolve=(--resolve localhost:443:127.0.0.1) +curl_common=(--insecure --fail --silent --show-error "${resolve[@]}") + +for unit in apache2.service mariadb.service espocrm-websocket.service \ + multi-user.target; do + systemctl --quiet is-active "$unit" || { + echo "$unit is not active" >&2 + exit 1 + } +done + +curl "${curl_common[@]}" "$base/" >"$home_file" +grep -q 'EspoCRM' "$home_file" || { + echo 'EspoCRM application page is missing' >&2 + exit 1 +} +site_url=$(runuser -u www-data -- sh -c \ + 'cd /var/www/espocrm && php command.php config:get siteUrl') +test "$site_url" = 'https://localhost' || { + echo 'EspoCRM site URL does not match firstboot input' >&2 + exit 1 +} + +credentials=$(printf 'admin:%s' "$app_password" | base64 -w0) +curl "${curl_common[@]}" \ + --header "Espo-Authorization: $credentials" \ + "$api/App/user" >"$auth_file" +token=$(python3 - "$auth_file" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1], encoding='utf-8')) +assert data['user']['userName'] == 'admin' +assert data['user']['type'] == 'admin' +print(data['token']) +PY +) +token_credentials=$(printf 'admin:%s' "$token" | base64 -w0) + +curl "${curl_common[@]}" \ + --header "Espo-Authorization: $token_credentials" \ + --header 'Content-Type: application/json' \ + --data '{"firstName":"TurnKey","lastName":"Acceptance","status":"New"}' \ + "$api/Lead" >"$lead_file" +lead_id=$(python3 - "$lead_file" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1], encoding='utf-8')) +assert data['firstName'] == 'TurnKey' +assert data['lastName'] == 'Acceptance' +print(data['id']) +PY +) +curl "${curl_common[@]}" \ + --header "Espo-Authorization: $token_credentials" \ + "$api/Lead/$lead_id" >"$lead_file" +python3 - "$lead_file" "$lead_id" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1], encoding='utf-8')) +assert data['id'] == sys.argv[2] +assert data['name'] == 'TurnKey Acceptance' +PY +mysql --batch --skip-column-names espocrm \ + --execute "SELECT id FROM lead WHERE id = '$lead_id'" | grep -Fxq "$lead_id" + +runuser -u www-data -- sh -c 'cd /var/www/espocrm && php -f cron.php' +mysqladmin --user=root --password="$db_password" ping 2>/dev/null | + grep -q 'mysqld is alive' +dpkg-query -W adminer webmin-apache webmin-mysql webmin-postfix postfix >/dev/null + +installed=$(runuser -u www-data -- sh -c \ + 'cd /var/www/espocrm && php command.php version' | tail -1) +test "$installed" = '10.0.6' +curl --fail --silent --show-error \ + "https://api.github.com/repos/espocrm/espocrm/releases/tags/$installed" \ + >"$release_file" +curl --fail --silent --show-error \ + https://api.github.com/repos/espocrm/espocrm/releases/latest >"$latest_file" +read -r asset_digest latest_version < <(python3 - \ + "$release_file" "$latest_file" <<'PY' +import json +import sys + +release = json.load(open(sys.argv[1], encoding='utf-8')) +latest = json.load(open(sys.argv[2], encoding='utf-8')) +asset = next(a for a in release['assets'] if a['name'] == f"EspoCRM-{release['tag_name']}.zip") +print(asset['digest'].removeprefix('sha256:'), latest['tag_name']) +PY +) +test "$asset_digest" = \ + '88bcb177dbe38b79ec3c13d491d78b727dc14fe18fc91deea1bfa72b313f7fb4' +test "$(printf '%s\n%s\n' "$installed" "$latest_version" | sort -V | head -1)" = \ + "$installed" + +cat >"$result" <