From a1128b9602ccaac8018cc28357ef7b6d51f2df6b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 2 Nov 2026 06:00:37 +0530 Subject: [PATCH 1/5] fix(logrotate): cap host syslog and mail.log growth Postfix containers log through the host's /dev/log, so a logging loop lands in /var/log/syslog and /var/log/mail.log. Distros rotate those weekly with no size limit, and logrotate runs once a day, so a flood can fill the disk between runs and take the global DB down with it. A one-time migration now adds `maxsize 2G` to every stanza of the rsyslog logrotate rule that has no size limit yet, keeping the rest of the file as it is (a stanza or file that already sets maxsize or size is left alone, an unparseable or symlinked file is left alone with a warning, and the result must pass `logrotate -d` with no new errors). The original is saved under the EasyEngine backup dir. It also makes logrotate run hourly so the limit is checked in time: a logrotate.timer drop-in on systemd hosts (a stopped timer stays stopped), or an hourly cron job that runs the daily logrotate script on hosts that use cron. An existing drop-in or job is left alone. --- ...61102100000_easyengine_cap_host_syslog.php | 22 ++ php/EE/Logrotate/Utils.php | 281 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 migrations/db/20261102100000_easyengine_cap_host_syslog.php diff --git a/migrations/db/20261102100000_easyengine_cap_host_syslog.php b/migrations/db/20261102100000_easyengine_cap_host_syslog.php new file mode 100644 index 000000000..959d49b8b --- /dev/null +++ b/migrations/db/20261102100000_easyengine_cap_host_syslog.php @@ -0,0 +1,22 @@ +getMessage() ); + } + } + + /** + * Add maxsize to every stanza of a host logrotate config that has no size limit, keeping everything else. + * + * @param string $file Config file path. + * @param string $max_size logrotate size, e.g. 2G. + * @return bool Whether the config is capped. + */ + private static function add_maxsize_to_config( $file, $max_size ) { + + // Replacing a symlink would detach the file from whatever manages it. + if ( is_link( $file ) || ! is_file( $file ) || ! is_readable( $file ) ) { + \EE::warning( "Not adding maxsize to $file: it is not a regular readable file." ); + return false; + } + + $contents = file_get_contents( $file ); + $updated = false === $contents ? null : self::insert_maxsize( $contents, $max_size ); + + if ( null === $updated ) { + \EE::warning( "Not adding maxsize to $file: unable to parse it. Add 'maxsize $max_size' to its rules by hand." ); + return false; + } + + if ( $updated === $contents ) { + \EE::debug( "Host logrotate config already has a size limit: $file" ); + return true; + } + + $tmp = tempnam( sys_get_temp_dir(), 'ee-logrotate-' ); + if ( false === $tmp || false === file_put_contents( $tmp, $updated ) ) { + \EE::warning( "Unable to add maxsize to $file: cannot write a temporary copy." ); + return false; + } + $new_errors = array_diff( self::dry_run_errors( $tmp ), self::dry_run_errors( $file ) ); + unlink( $tmp ); + + if ( ! empty( $new_errors ) ) { + \EE::warning( "Not adding maxsize to $file: logrotate rejects the result. " . implode( ' ', $new_errors ) ); + return false; + } + + $backup = EE_BACKUP_DIR . '/logrotate/' . basename( $file ); + if ( ! is_dir( dirname( $backup ) ) ) { + mkdir( dirname( $backup ), 0755, true ); + } + if ( ! copy( $file, $backup ) ) { + \EE::warning( "Not adding maxsize to $file: unable to back it up to $backup." ); + return false; + } + + // The trailing ~ is in logrotate's tabooext, so a run in between never reads the half-written file. + $new = $file . '.ee-new~'; + $stat = stat( $file ); + if ( + false === file_put_contents( $new, $updated ) + || ! chmod( $new, $stat['mode'] & 07777 ) + || ! chown( $new, $stat['uid'] ) + || ! chgrp( $new, $stat['gid'] ) + || ! rename( $new, $file ) + ) { + if ( is_file( $new ) ) { + unlink( $new ); + } + \EE::warning( "Unable to add maxsize to $file." ); + return false; + } + + \EE::log( "Added 'maxsize $max_size' to $file (original saved to $backup)." ); + + return true; + } + + /** + * Insert a maxsize line at the top of every stanza that has no maxsize or size directive. + * + * @param string $contents Config contents. + * @param string $max_size logrotate size. + * @return string|null Updated contents (unchanged when every stanza is limited), or null when unparseable. + */ + private static function insert_maxsize( $contents, $max_size ) { + + $lines = preg_split( '/(?<=\n)/', $contents, -1, PREG_SPLIT_NO_EMPTY ); + $scripts = [ 'postrotate', 'prerotate', 'firstaction', 'lastaction', 'preremove' ]; + $global_limit = false; + $in_stanza = false; + $in_script = false; + $output = ''; + $stanza = []; + $limited = false; + $indent = null; + + foreach ( $lines as $line ) { + $trimmed = trim( $line ); + $word = strtolower( (string) strtok( $trimmed, " \t=" ) ); + + if ( $in_script ) { + $in_script = 'endscript' !== $word; + } elseif ( '' === $trimmed || '#' === $trimmed[0] ) { + // Comment or blank line. + } elseif ( ! $in_stanza ) { + if ( false !== strpos( $trimmed, '}' ) ) { + return null; + } + if ( false !== strpos( $trimmed, '{' ) ) { + if ( '{' !== substr( $trimmed, -1 ) || 1 !== substr_count( $trimmed, '{' ) ) { + return null; + } + $in_stanza = true; + $limited = $global_limit; + $indent = null; + $stanza = [ $line ]; + continue; + } + $global_limit = $global_limit || in_array( $word, [ 'maxsize', 'size' ], true ); + } elseif ( '}' === $trimmed[0] ) { + if ( false !== strpos( $trimmed, '{' ) ) { + return null; + } + if ( ! $limited ) { + $indent = null === $indent ? ' ' : $indent; + array_splice( $stanza, 1, 0, [ $indent . '# Added by EasyEngine: rotate early when a log outgrows this.' . "\n", $indent . "maxsize $max_size\n" ] ); + } + $output .= implode( '', $stanza ); + $stanza = []; + $in_stanza = false; + } elseif ( false !== strpos( $trimmed, '{' ) || false !== strpos( $trimmed, '}' ) ) { + return null; + } else { + $in_script = in_array( $word, $scripts, true ); + $limited = $limited || in_array( $word, [ 'maxsize', 'size' ], true ); + if ( null === $indent ) { + $indent = substr( $line, 0, strspn( $line, " \t" ) ); + } + } + + if ( $in_stanza ) { + $stanza[] = $line; + } else { + $output .= $line; + } + } + + return $in_stanza || $in_script ? null : $output; + } + + /** + * Errors `logrotate -d` reports for a config, with its path masked so two copies compare equal. + * + * @param string $file Config file path. + * @return array + */ + private static function dry_run_errors( $file ) { + + $result = \EE::launch( 'logrotate -d ' . escapeshellarg( $file ) . ' 2>&1', false, true ); + $errors = []; + + foreach ( explode( "\n", $result->stdout ) as $line ) { + if ( 0 === strpos( $line, 'error:' ) ) { + $errors[] = trim( str_replace( $file, '', $line ) ); + } + } + + // A failure without an error line (e.g. a crash) still counts as one. + if ( 0 !== $result->return_code && empty( $errors ) ) { + $errors[] = 'logrotate exited with ' . $result->return_code; + } + + return $errors; + } + + /** + * Run logrotate hourly, so maxsize is checked before a flood fills the disk. + * + * systemd hosts get a logrotate.timer drop-in; hosts that run logrotate from cron.daily get an + * hourly cron job that runs the same script. An existing drop-in or job is left as it is. + */ + private static function schedule_hourly_rotation() { + + if ( is_dir( '/run/systemd/system' ) && self::command_exists( 'systemctl' ) ) { + $load_state = \EE::launch( 'systemctl show -p LoadState logrotate.timer 2>/dev/null', false, true ); + + if ( 'LoadState=loaded' === trim( $load_state->stdout ) ) { + self::install_timer_dropin(); + return; + } + } + + if ( ! is_executable( self::CRON_DAILY_FILE ) || ! is_dir( dirname( self::CRON_HOURLY_FILE ) ) ) { + \EE::debug( 'Neither logrotate.timer nor cron.daily runs logrotate; not scheduling hourly rotation.' ); + return; + } + + if ( file_exists( self::CRON_HOURLY_FILE ) ) { + return; + } + + $script = '#!/bin/sh' . "\n" + . '# Added by EasyEngine: run the daily logrotate job hourly too, so maxsize limits are checked every hour.' . "\n" + . '[ -x ' . self::CRON_DAILY_FILE . ' ] || exit 0' . "\n" + . 'exec ' . self::CRON_DAILY_FILE . "\n"; + + if ( false === file_put_contents( self::CRON_HOURLY_FILE, $script ) || ! chmod( self::CRON_HOURLY_FILE, 0755 ) ) { + \EE::warning( 'Unable to write the hourly logrotate cron job: ' . self::CRON_HOURLY_FILE ); + return; + } + + \EE::log( 'Scheduled logrotate hourly in ' . self::CRON_HOURLY_FILE . '.' ); + } + + /** + * Install the logrotate.timer drop-in that replaces the daily schedule with an hourly one. + */ + private static function install_timer_dropin() { + + if ( file_exists( self::TIMER_DROPIN_FILE ) ) { + \EE::debug( 'logrotate.timer drop-in already exists: ' . self::TIMER_DROPIN_FILE ); + return; + } + + $dir = dirname( self::TIMER_DROPIN_FILE ); + if ( ! is_dir( $dir ) && ! mkdir( $dir, 0755, true ) ) { + \EE::warning( 'Unable to create ' . $dir ); + return; + } + + // The empty OnCalendar= clears the unit's daily schedule. + $dropin = '# Added by EasyEngine: rotate hourly, so maxsize limits are checked every hour.' . "\n" + . "[Timer]\nOnCalendar=\nOnCalendar=hourly\n"; + + if ( false === file_put_contents( self::TIMER_DROPIN_FILE, $dropin ) || ! chmod( self::TIMER_DROPIN_FILE, 0644 ) ) { + \EE::warning( 'Unable to write the logrotate.timer drop-in: ' . self::TIMER_DROPIN_FILE ); + return; + } + + // try-restart leaves a timer the admin stopped or disabled alone. + $result = \EE::launch( 'systemctl daemon-reload && systemctl try-restart logrotate.timer', false, true ); + + if ( 0 !== $result->return_code ) { + \EE::warning( 'Unable to reload logrotate.timer: ' . trim( $result->stderr . ' ' . $result->stdout ) ); + return; + } + + \EE::log( 'Scheduled logrotate.timer hourly in ' . self::TIMER_DROPIN_FILE . '.' ); + } + /** * Configure logrotate for EasyEngine CLI, site, and nginx-proxy logs. */ From 7ca263d360d3dfe646425de768136cdfc71579bd Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 2 Nov 2026 06:03:06 +0530 Subject: [PATCH 2/5] fix(logrotate): add maxsize to EasyEngine's own log rules The ee_cli, ee_sites and ee_nginx_proxy rules rotate daily with no size limit. With logrotate now running hourly, `maxsize 1G` rotates a runaway CLI, site or proxy log within the hour instead of letting it grow until the next day. --- php/EE/Logrotate/Utils.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/php/EE/Logrotate/Utils.php b/php/EE/Logrotate/Utils.php index 29b0adeff..7c6d98eba 100644 --- a/php/EE/Logrotate/Utils.php +++ b/php/EE/Logrotate/Utils.php @@ -11,6 +11,7 @@ class Utils { const EE_LOG_CONFIG_FILE = '/etc/logrotate.d/ee_cli'; const SITES_CONFIG_FILE = '/etc/logrotate.d/ee_sites'; const NGINX_PROXY_CONFIG_FILE = '/etc/logrotate.d/ee_nginx_proxy'; + const EE_LOG_MAX_SIZE = '1G'; // rsyslog's rule (Debian/Ubuntu and RHEL 8+), and its older RHEL name. const HOST_SYSLOG_CONFIG_FILES = [ '/etc/logrotate.d/rsyslog', '/etc/logrotate.d/syslog' ]; @@ -615,10 +616,12 @@ private static function get_legacy_roots() { private static function get_ee_log_config() { $root_dir = rtrim( EE_ROOT_DIR, '/' ); + $max_size = self::EE_LOG_MAX_SIZE; return << Date: Mon, 2 Nov 2026 06:05:37 +0530 Subject: [PATCH 3/5] fix(logrotate): keep same-day size rotations apart under dateext With dateext and the default day-only suffix, a second rotation on the same day fails with "destination already exists", so maxsize stopped capping a flood after the first rotation of the day (RHEL-family hosts enable dateext in logrotate.conf by default). The host stanzas that get maxsize also get `dateformat -%Y%m%d-%s` unless the stanza or file sets its own dateformat, and EasyEngine's own rules get the same. It changes nothing when dateext is off, and keeps dated archive names when it is on. --- php/EE/Logrotate/Utils.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/php/EE/Logrotate/Utils.php b/php/EE/Logrotate/Utils.php index 7c6d98eba..197ecdbb4 100644 --- a/php/EE/Logrotate/Utils.php +++ b/php/EE/Logrotate/Utils.php @@ -12,6 +12,8 @@ class Utils { const SITES_CONFIG_FILE = '/etc/logrotate.d/ee_sites'; const NGINX_PROXY_CONFIG_FILE = '/etc/logrotate.d/ee_nginx_proxy'; const EE_LOG_MAX_SIZE = '1G'; + // With dateext, a day-only suffix makes a second size rotation on the same day fail ("destination already exists"). + const DATE_FORMAT = '-%Y%m%d-%s'; // rsyslog's rule (Debian/Ubuntu and RHEL 8+), and its older RHEL name. const HOST_SYSLOG_CONFIG_FILES = [ '/etc/logrotate.d/rsyslog', '/etc/logrotate.d/syslog' ]; @@ -134,7 +136,8 @@ private static function add_maxsize_to_config( $file, $max_size ) { } /** - * Insert a maxsize line at the top of every stanza that has no maxsize or size directive. + * Insert a maxsize line at the top of every stanza that has no maxsize or size directive, plus a dateformat that + * keeps same-day rotations apart unless the stanza or file sets its own. * * @param string $contents Config contents. * @param string $max_size logrotate size. @@ -145,11 +148,13 @@ private static function insert_maxsize( $contents, $max_size ) { $lines = preg_split( '/(?<=\n)/', $contents, -1, PREG_SPLIT_NO_EMPTY ); $scripts = [ 'postrotate', 'prerotate', 'firstaction', 'lastaction', 'preremove' ]; $global_limit = false; + $global_date = false; $in_stanza = false; $in_script = false; $output = ''; $stanza = []; $limited = false; + $dated = false; $indent = null; foreach ( $lines as $line ) { @@ -170,18 +175,24 @@ private static function insert_maxsize( $contents, $max_size ) { } $in_stanza = true; $limited = $global_limit; + $dated = $global_date; $indent = null; $stanza = [ $line ]; continue; } $global_limit = $global_limit || in_array( $word, [ 'maxsize', 'size' ], true ); + $global_date = $global_date || 'dateformat' === $word; } elseif ( '}' === $trimmed[0] ) { if ( false !== strpos( $trimmed, '{' ) ) { return null; } if ( ! $limited ) { $indent = null === $indent ? ' ' : $indent; - array_splice( $stanza, 1, 0, [ $indent . '# Added by EasyEngine: rotate early when a log outgrows this.' . "\n", $indent . "maxsize $max_size\n" ] ); + $added = [ $indent . '# Added by EasyEngine: rotate a log early when it outgrows maxsize.' . "\n", $indent . "maxsize $max_size\n" ]; + if ( ! $dated ) { + $added[] = $indent . 'dateformat ' . self::DATE_FORMAT . "\n"; + } + array_splice( $stanza, 1, 0, $added ); } $output .= implode( '', $stanza ); $stanza = []; @@ -191,6 +202,7 @@ private static function insert_maxsize( $contents, $max_size ) { } else { $in_script = in_array( $word, $scripts, true ); $limited = $limited || in_array( $word, [ 'maxsize', 'size' ], true ); + $dated = $dated || 'dateformat' === $word; if ( null === $indent ) { $indent = substr( $line, 0, strspn( $line, " \t" ) ); } @@ -615,13 +627,15 @@ private static function get_legacy_roots() { */ private static function get_ee_log_config() { - $root_dir = rtrim( EE_ROOT_DIR, '/' ); - $max_size = self::EE_LOG_MAX_SIZE; + $root_dir = rtrim( EE_ROOT_DIR, '/' ); + $max_size = self::EE_LOG_MAX_SIZE; + $date_format = self::DATE_FORMAT; return << Date: Mon, 2 Nov 2026 06:08:21 +0530 Subject: [PATCH 4/5] fix(logrotate): compare dry runs without line numbers and skip a no-op cron job The inserted lines shift the line numbers of errors the original rule already has, so an unrelated pre-existing error made the edit look like it added a new one and the cap was refused. Line numbers are now masked along with the path. On a systemd host without a usable logrotate.timer (e.g. masked), the packaged cron.daily script exits early because systemd runs, so the hourly job would never rotate anything. EasyEngine now warns that maxsize is only checked when logrotate runs instead of installing that job. --- php/EE/Logrotate/Utils.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/php/EE/Logrotate/Utils.php b/php/EE/Logrotate/Utils.php index 197ecdbb4..05526d849 100644 --- a/php/EE/Logrotate/Utils.php +++ b/php/EE/Logrotate/Utils.php @@ -219,7 +219,7 @@ private static function insert_maxsize( $contents, $max_size ) { } /** - * Errors `logrotate -d` reports for a config, with its path masked so two copies compare equal. + * Errors `logrotate -d` reports for a config, with its path and line numbers masked so two copies compare equal. * * @param string $file Config file path. * @return array @@ -231,7 +231,8 @@ private static function dry_run_errors( $file ) { foreach ( explode( "\n", $result->stdout ) as $line ) { if ( 0 === strpos( $line, 'error:' ) ) { - $errors[] = trim( str_replace( $file, '', $line ) ); + // The inserted lines shift the line numbers of errors the original already has. + $errors[] = trim( preg_replace( '/:\d+/', '', str_replace( $file, '', $line ) ) ); } } @@ -265,6 +266,12 @@ private static function schedule_hourly_rotation() { return; } + // Newer cron.daily scripts exit when systemd runs, leaving rotation to logrotate.timer. + if ( is_dir( '/run/systemd/system' ) && false !== strpos( (string) file_get_contents( self::CRON_DAILY_FILE ), '/run/systemd/system' ) ) { + \EE::warning( 'logrotate.timer is not available and ' . self::CRON_DAILY_FILE . ' defers to it, so logrotate is not scheduled hourly and maxsize is only checked when logrotate runs.' ); + return; + } + if ( file_exists( self::CRON_HOURLY_FILE ) ) { return; } From f13d3f6cc3d4d65740c2424141a6f5e721090152 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 2 Nov 2026 06:11:45 +0530 Subject: [PATCH 5/5] fix(logrotate): keep the host rule backup private copy() created the backup with the default umask, so a rule restricted to root (which may hold credentials in its scripts) was saved world-readable. The backup dir is now 0700 and the copy 0600. --- php/EE/Logrotate/Utils.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/php/EE/Logrotate/Utils.php b/php/EE/Logrotate/Utils.php index 05526d849..b6c4fd9b0 100644 --- a/php/EE/Logrotate/Utils.php +++ b/php/EE/Logrotate/Utils.php @@ -104,11 +104,16 @@ private static function add_maxsize_to_config( $file, $max_size ) { return false; } + // Private: a rule restricted to root may hold credentials in its scripts. $backup = EE_BACKUP_DIR . '/logrotate/' . basename( $file ); + $umask = umask( 0077 ); if ( ! is_dir( dirname( $backup ) ) ) { - mkdir( dirname( $backup ), 0755, true ); + mkdir( dirname( $backup ), 0700, true ); } - if ( ! copy( $file, $backup ) ) { + chmod( dirname( $backup ), 0700 ); + $copied = copy( $file, $backup ) && chmod( $backup, 0600 ); + umask( $umask ); + if ( ! $copied ) { \EE::warning( "Not adding maxsize to $file: unable to back it up to $backup." ); return false; }