You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(site): stop following symlinks from a site's php config volume in restore, update --php and migrations - #522
Stacked on #519 and #520, merge after them. This branch is #520's head plus a merge of #519's head, so the diff below also shows their commits until they're merged; the commits of this PR are the ones after the merge commit.
What
Host-root code that reads or writes a site's php config volume no longer follows anything the site's php container put there: ee site restore, ee site update --php, ee site enable and #519's php.ini migration all go through one set of checked helpers.
Why
A site's config/php is a link to its config_php volume, which the php container mounts at /usr/local/etc (as root, and as www-data after a PHP switch, which chowns the volume's top directory to www-data). The container can therefore replace php/, php/conf.d, php-fpm.d or any file in them with a symlink, FIFO or device node. EasyEngine then followed it as root:
restore_php_conf() ran rsync -a into config/php/php-fpm.d/, config/php/php/php.ini and config/php/php/conf.d/custom.ini, so a swapped directory sent the backed-up files into whatever host directory it pointed at.
update_php() removed everything in the volume and mirrored the new version's config into it while the new php container was already running, so a swap during that window redirected deletes and writes. A symlinked custom.ini or easyengine.conf was copied into the backup as a link and then read back through it into the new config, so the contents of a host file ended up in the site's config.
enable() recursively chowned config/php/php/conf.d while the container may be running.
PHP has no O_NOFOLLOW, openat() or O_PATH, and it resolves symlinks in user space before open(), so a path check alone is always racy. #520 added open_container_file(), which opens once and checks the handle (the kernel's path of the open fd from /proc/self/fd, fstat() against lstat(), one hard link for writes, non-blocking against FIFOs). This PR builds on it.
How
site-utils.php:
open_container_file() now lstat()s the path first and refuses a missing file, a symlink, FIFO, device or directory without opening it, and the opened inode must be the one it saw.
get_container_file_contents() and put_container_file_contents() are the path-level read and in-place write (owner, mode and inode kept; compared read-only first, so an unchanged file isn't opened for writing; an optional expected content refuses a file that changed since it was checked; a short write puts the old content back). Neither creates a file.
check_plain_tree() refuses a tree that holds anything but directories and regular files; chown_container_tree() chowns directories and regular files only, with lchown(), re-checking each directory's real path.
site_php_conf_dir() takes the directory below config/php (default php/conf.d, as before).
Restore: restore_php_conf_files() replaces the three rsync calls and restores the same set: the whole php-fpm.d/ tree including its subfolders, php/php.ini and php/conf.d/custom.ini. The backup came from the same volume, so it may hold only directories and regular files: symlinks, FIFOs, devices and names with control characters are refused with a warning (unzip already strips ../ and leading / from entry names, so those land inside the backup dir). php.ini and custom.ini are collected first; the php-fpm.d walk stops at 1000 entries with one warning, and nothing deeper than 16 levels is restored. An existing file is written over the site's regular file in place through a checked handle. A directory or file the site lacks (e.g. a pool file or subfolder added by hand, restored into a fresh site) is created from inside the running php container, one level at a time, parents first: a first docker exec changes into the parent, checks that it is a real directory there (cd -P + pwd -P, no symlink in its path) and reads its owner; a second one, run as that owner (-u uid:gid), does the same check and creates the entry relative to it (mkdir -m, or cat > with noclobber, then chmod). A symlink the container made can't point at the host, a directory swapped after the check can't redirect the write, and a user who can write the parent can't get root to change anything that user couldn't. The backed-up permission bits (rwx; setuid/setgid/sticky aren't taken from the backup) are applied the same way, to created entries and to existing ones whose bits differ. A created entry belongs to the owner and group of the directory it is created in (the backup holds no owner: 7-Zip's zip stores none, and unzip extracts as root); an existing one keeps its own, and its mode is only changed when that parent's owner may change it (otherwise warned). A directory or file that can't be restored (swapped for a symlink, not a plain directory or regular file in the site, or missing while php isn't running) is warned about; its files, and those below it, are kept under .backup/<site>-php-conf-not-restored-<time>/. On macOS, where config/php isn't mounted, a missing directory or file is skipped and no mode is set, which is what rsync's copy amounted to there. A backup whose php directory is itself a link (some 7-Zip versions store config/php that way) is refused. keep_session_upload_progress_off() (fix(restore): keep session upload progress off after restoring an older backup #520) uses the same helpers.
update --php: the new image is pulled first (docker-compose pull php), while the site still runs. Then the php container, the only one that mounts the volume, is stopped; a running container that still mounts the volume (e.g. from a custom compose file) stops the update; the volume must hold only directories and regular files, or the update stops with "Nothing was changed" and the container is started again. The backup of the old config is a fresh copy (an older backup of the same version could hold a link that the mirror would write through; it is kept aside with a timestamp). The new version's config is put in place before the site starts on the new PHP version, so the new PHP never runs with the old version's config. A failure after the pull is rolled back: php is stopped again if enable() had started it, the old config is copied back from the backup (only while no other container mounts the volume), the old compose file is written and php is started on the old image (enable() is now called so that it throws instead of exiting).
enable(): conf.d is given to www-data with chown_container_tree(); a failure is a warning, as the site can run without it.
fix(templates): drop deprecated session ID ini settings from the PHP 8.4 and 8.5 configs #519's migration reads and rewrites php.ini through the helpers (capped at 1 MB); a swapped php.ini or php/ directory is now a per-site warning instead of a silent skip, a backup it no longer needs is removed, and a CR-only php.ini is split into lines (it used to be one line, so a match emptied the file).
Behaviour changes
ee site restore overwrites existing files' content in place and creates missing directories and files (including subfolders of php-fpm.d, at any depth) from inside the running php container. Restored: content, directory structure, permission bits (rwx). Not restored: owner (created entries get their parent directory's owner, existing ones keep theirs; rsync made everything root-owned, since unzip extracts as root), setuid/setgid/sticky bits and mtime; the mode of an existing entry the parent directory's owner can't change (e.g. a root-owned file in a www-data directory) is left as it is, with a warning. Missing entries need the php container running; without it they are kept under .backup/ with a warning, and modes are not set (also warned). Entries in the site that the backup lacks are left alone, as with rsync (no --delete).
ee site update --php stops the php container for the duration of the config switch (after pulling the image) and refuses a config volume that holds symlinks, FIFOs or devices. The image's /usr/local/etc and the template archives have none.
The old chown -R www-data: config/php only ever changed the volume's top directory (chown doesn't descend through a symlink named on the command line); that's unchanged.
What is left
chown_container_tree() (used by enable(), where the container may be running) re-checks each directory's real path before lchown(), but a directory swapped between that check and the lchown() can still redirect it; there is no fchownat() in PHP.
Between the lstat() and the open() inside open_container_file() (microseconds), a symlink, FIFO or device renamed into place is still opened, non-blocking, and closed again before any read or write; opening some devices has side effects. Only an open with O_NOFOLLOW/openat(), which PHP lacks, or a write from inside a container that mounts only the volume avoids that. On macOS config/php is a host directory and only custom.ini is bind-mounted, so the container can't swap anything there.
Not covered here: the backup side. 7z u conf.zip php follows symlinks inside the volume (it archives the target's content) and blocks on a FIFO; the restore refuses links but can't tell such content from a real file.
The in-container chmod follows a symlink swapped in after its check; it runs as the parent directory's owner, so it can't change anything that owner couldn't, and it stays inside the container.
Testing
Helper checks on PHP 7.4 and 8.5 (CI, as root): regular file read/write/inode kept/size cap/expected-content mismatch, missing file (nothing created), symlinked file, symlinked parent directory, FIFO (no stall), device node, hard link, check_plain_tree() and chown_container_tree() (symlink target untouched).
Race in a throwaway process pair, 20000 rewrites while another process keeps swapping conf.d with a symlink to another directory: the old file-only check plus path write wrote into the other directory 286 (7.4) / 361 (8.5) times; the helpers 0 times.
Restore against throwaway site dirs on PHP 7.4 and 8.5 (CI, as root), with a stand-in for docker exec that runs the same in-container script on the host with the volume as its base (so a symlink there really points outside): a backup with subfolders three levels deep, an empty directory and a name starting with - restores fully into a site that lacks them (php/conf.d missing too), with the backed-up modes, created entries owned like their parent and existing files keeping their owner, and a second restore needs no in-container step; in a www-data directory, created entries belong to www-data and a root-owned file's mode is left alone with a warning; a php-fpm.d with 1200 files still gets php.ini and custom.ini restored with one warning for the limit, and a tree 18 levels deep stops at 16; a backup holding a symlinked file, a symlinked directory, a relative symlink, a FIFO, a control-character name or a symlinked php/conf.d has those refused and the rest restored; a crafted zip with a symlink entry, a file through it, ../ and absolute entries, extracted with unzip, writes nothing outside and its plain entry is restored; a site whose php-fpm.d/pool or pool/sub is a symlink to an outside directory, or whose www.conf is one, gets a warning, the files kept under .backup/ and the outside directory untouched; the in-container step refuses a symlinked parent, .., absolute paths, other roots and setuid modes on its own; with php stopped, existing files are restored and missing ones kept; two races, 300 restores each: another process keeps swapping php-fpm.d/pool between the directory and a symlink to an outside directory while restore creates files in it, and a www-data process keeps swapping pool/m.conf with a symlink to a root-owned outside file while restore rewrites it and sets its mode: both show restores that went through and restores that were refused, and 0 changes outside (content, mode, owner).
On a runner with core develop: with develop's site-command, a restore with a swapped conf.d writes into the host directory and update --php copies a host file into the site's custom.ini through a symlinked custom.ini; with this branch the host directory is untouched, the restore warns, update --php refuses with the php container running again on the old version, a clean restore brings back the backed-up custom.ini with upload progress Off, a restore recreates an easyengine.conf missing in the site from inside the container, a restore recreates a removed php-fpm.d/extra/sub/deep/ tree with its files, the backed-up modes (750/711/755/600) and www-data as owner like its parent, a restore with php-fpm.d/extra (or extra/sub) swapped for a symlink to a host directory refuses it, keeps its files and leaves the host directory untouched, a backup archive with symlink entries, ../ and absolute entries added writes nothing outside the site and restores its plain entries, an update --php that fails after the switch began (template archive removed) is rolled back to the old config, compose file and PHP version, and a clean update --php serves the new PHP version with custom.ini carried over and the old config backed up. Behat passes.
Release order
site-type-wp and site-type-php use get_container_file_contents()/put_container_file_contents() from this PR when they exist and fall back to a path check otherwise, so they work with an older site-command, but they get the race-safe behaviour only together with this one.
…8.4 and 8.5 configs
session.sid_length and session.sid_bits_per_character are deprecated as of PHP 8.4: setting either one raises an E_DEPRECATED at startup whenever error_reporting includes E_DEPRECATED. PHP's own php.ini-production for 8.4 and later no longer sets them.
Remove both directives and their quick-reference entries from php/php.ini in php84.zip and php85.zip. These archives are what `ee site update --php=<version>` installs, so a site switched to 8.4 or 8.5 no longer gets the deprecated settings. Only that entry changes; every other entry is byte-identical.
Newly created session IDs fall back to PHP's defaults, 32 characters of 0-9a-f instead of 26 characters of 0-9a-v. IDs issued before the change are still accepted.
…d 8.5 sites
A site's php.ini is copied from the PHP image when the site is created and from the version's archive when its PHP version is switched, so existing PHP 8.4 and 8.5 sites keep session.sid_length and session.sid_bits_per_character even with the fixed images and archives. This container migration gives them the same php.ini a new site gets.
It handles php and wp sites on PHP 8.4, 8.5 or `latest` (which runs the php8.4 image), enabled or not. In config/php/php/php.ini it removes every active session.sid_length and session.sid_bits_per_character line, plus the comments shipped right above them and the matching quick reference entry, but only where those comments match the shipped text. Every other line, custom.ini and the other config files stay as they are. A php.ini EasyEngine shipped ends up byte-identical to the one in the archives, and a file that has neither directive is left alone, so running it again changes nothing.
Values other than the shipped 26 and 5 are removed too, with a warning naming the site, the removed line and the backup. Both settings are deprecated as of PHP 8.4 and go away in PHP 9, and php.ini is replaced wholesale on every PHP version switch anyway, so custom.ini is the only place where overrides survive. Keeping an edited value would leave the site with the deprecation and out of step with a switched site.
The previous file is saved as .backup/<site>/php.ini.with-session-sid-settings, and the new file keeps its mode and owner and only replaces the old one once fully written. PHP-FPM reads php.ini only when it starts, so the php container of an enabled site gets the same graceful reload `ee site reload --php` uses (USR2 to FPM) if it's running; stopped or disabled sites pick the file up when they start, and no container is recreated. down() puts the saved files back and reloads FPM again, so a failure later in the upgrade restores the old state. A site that can't be migrated only gets a warning, so it doesn't block the upgrade of the others.
The PHP config archive is picked by the version a site is switched to (`php<version without dots>.zip`), and `ee site update --php` only accepts 5.6 to 8.5, so `latest` never selects it. A site created with `--php=latest` runs the php8.4 image and gets its config from that image, and switching such a site to another version uses the target version's archive. Nothing else in site-command, core, site-type-wp or site-type-php refers to it.
It also still carried the session ID settings deprecated as of PHP 8.4.
…er backup
A restore copies custom.ini and php.ini from the backup over the site's files. A backup taken before v4.13.3 has neither the session.upload_progress.enabled = Off line that v4.13.3 added to custom.ini nor a php.ini that sets it, so the restored site ran with upload progress back On, PHP's default.
After restoring the PHP config, add the line again at the top of custom.ini unless the restored file already sets the directive outside a [PATH=] or [HOST=] section, so an explicit On or Off is kept, as in the v4.13.3 migrations. The site reload at the end of the restore applies it.
The check that finds those sections now lets PHP's ini parser decide what each header line is. Before, a header was only recognised when it was unquoted and LF-terminated, so in a CRLF file, or with a quoted name like ["PATH=/x"], a setting inside the section was taken as a global one and the line was not added.
The site's php container can write to the directory that holds php.ini (www-data owns it after a PHP version switch), so it could plant a symlink at the predictable php.ini.ee-tmp path and have the upgrade write, chmod and chown a host file through it.
The file is now opened once, checked through the open handle to be the same regular, singly linked file as the path (no symlink swapped in after the check, no hard link to another file), and read, written and truncated through that handle. No other path is created or followed, and mode and owner stay as they are. The removal only shortens the file, so writing over it needs no new space; if the write fails anyway, the old content is written back. The backup is written from the content that was read, and down() restores through the same checks.
The directory that holds php.ini is root-owned by default: a PHP version switch makes only conf.d owned by www-data, not the php directory itself. It is still mounted into the site's php container, which is why the rewrite trusts no path in it.
Find the start of a [PATH=]/[HOST=] section by letting PHP's ini parser read up to every '[' and ']' instead of only at the start of an LF-separated line. A header after a CR-only line ending, a second header on the same line as in [PATH=/x][Session], and a quoted name that contains a newline or ']' are now found, so a setting inside such a section no longer counts as global.
Read the part before such a section without sections, so an array entry like custom_flags[session.upload_progress.enabled] = Off isn't taken for the directive. A CR-only file gets CR line endings for the added lines.
A section that reuses the name of an earlier global key replaces that key in place, so the last entry of the parsed array isn't the header just read. Name the header by parsing it alone, and count it only when it adds that section, which still leaves header-like text in a comment alone.
Decide whether a '[' starts a section header by checking that the ini parser takes an entry at that point, instead of comparing the parsed array before and after the header. An earlier global array or key with the same name as a [PATH=] section no longer hides it, and header-like text in a comment, a value or an array key still doesn't count.
…5 configs
Add `session.upload_progress.enabled = Off` to php/php.ini in php81.zip through php85.zip, right after the commented-out default, the same two lines EasyEngine/dockerfiles#374 adds to the 8.1 to 8.5 images. These archives are what `ee site update --php=<version>` installs, so a site switched to one of these versions gets the same php.ini as a site created on it. A site's custom.ini is read after php.ini and still wins, so `session.upload_progress.enabled = On` there turns it back on.
The 5.6 to 8.0 archives stay as they are: their images are not rebuilt and keep PHP's default, and the archives mirror the image's php.ini. Every site's custom.ini already sets Off since v4.13.3.
Only the php/php.ini entry changes; every other entry is byte-identical.
…heck missed it
The v4.13.3 migrations of site-type-wp and site-type-php added `session.upload_progress.enabled = Off` to every custom.ini that didn't already set it globally, but their check took a setting inside a [PATH=] or [HOST=] section for a global one when the section header was quoted, shared its line with another header, was named like an earlier key, or ended with CRLF or CR, and it also counted an array entry keyed by the directive. Those sites still run with PHP's default On.
Add a container migration, ordered after those, that checks the custom.ini of every WordPress and PHP site again with the parser from the restore fix and adds the same lines at the top where the directive still isn't set globally, creating the file when it is missing. A global On or Off is kept; a global On is logged. A site that already has the line is left alone, so a second run changes nothing.
Each changed file is backed up under .backup/session-upload-progress-recheck-<time>/ and written in place, keeping owner, mode and inode. down() restores the files byte for byte. Running php containers of enabled sites get the same graceful FPM reload as in the v4.13.3 migrations, after the image migration and only when the container wasn't recreated. A failure on one site is a warning and its file is restored; the other sites go on.
ini_global_value() returns the value next to ini_sets_globally(), so the migration can tell an explicit On from an Off.
…lobal
PHP scopes entries only to a section whose name has something after PATH or HOST. Under a header named just [PATH] or [HOST], in any case, php-fpm applies them globally, but ini_global_part() cut the global part there, so a global On under such a header was taken as unset and the Off line was added in front of it for nothing.
…ration
custom.ini lives in a directory the site's php container can write to, so a symlink or hard link can appear there between the checks and the write, and the migration runs as root on the host. Read, back up, rewrite and restore the file through one handle, checked after opening to be the regular, singly linked file at that path, the same way the session ID migration handles php.ini. A missing custom.ini is created under an unguessable name and hard-linked into place, because fopen() follows a dangling symlink even in 'x' mode; its owner is set with lchown().
A failed restore inside the per-site error path now only warns instead of aborting the upgrade, the log names the backup directory only when a file was backed up, and the explicit-On check follows PHP's atoi() fallback for values like "1e-5".
…k migration
Only config/php is EasyEngine's own link to the site's config volume; php/ and conf.d below it belong to the container, which can swap either for a symlink to a host directory. Resolve conf.d before and after each open and refuse anything outside the site's config volume, so the migration never creates, rewrites, restores or removes a custom.ini elsewhere on the host. A created file is removed on rollback only while it is still the same file in that directory.
Read custom.ini read-only first and reopen it for writing only when the line has to be added, so a read-only or hard-linked file that already sets the directive is left alone without a warning; a file with other hard links is still never written. Skip files over 64 KB with a warning, since the check re-parses the text before every '['. A write that fails and can't be undone is left for down() but no longer counted or reloaded as done, and a failed backup cleanup no longer reads as a failed restore.
The restored custom.ini sits in the site's config volume, which its php container can change: between the checks and the write it could swap the file for a symlink, or conf.d for a symlink to a host directory, and file_put_contents() as root would follow it. A short write also left only part of the file behind.
Add helpers that open such a file once and verify the handle is the regular, singly linked file in the site's conf.d before anything is read or written through it: on Linux by the kernel's path of the open file from /proc/self/fd, which a directory swapped around the open can't fake, elsewhere by checking the path before and after. A missing file is created under an unguessable name and hard-linked into place, since fopen() follows a dangling symlink even in 'x' mode. The restore now reads read-only first, writes through the handle, and on a failed write puts the restored file back or removes the new one.
Its own handle checks compared paths before and after opening, which a container that swaps conf.d for a host symlink and back around the open can beat; the shared helpers also check the kernel's path of the open file. Clear PHP's realpath cache along with the stat cache before each check, and remove a custom.ini the migration created when writing it fails.
… directory
remove_container_file() and the owner change of a newly created custom.ini went by path after their checks, so a container that swapped conf.d or php/ for a host symlink in between could have a custom.ini outside the site removed or chowned. Both now enter the directory, check with getcwd() that it is the site's conf.d, and work on the bare file name, which keeps reaching that directory whatever its path points to later.
Reads of the file are capped at 64 KB (read_container_file()), so a file the container keeps growing can't exhaust memory or stall the ini check; the restore warns and leaves a larger custom.ini as it is.
…h the helpers
The size check used fstat() before reading, but the container can grow the file after it; read at most 64 KB plus one byte instead, also when comparing before the write and when restoring. Set the owner of a created custom.ini with chown_container_file().
Creating the file can't be confined to the site's conf.d from PHP: fopen() and link() expand the path to an absolute one, so a conf.d swapped for a host symlink at the wrong moment gets the new file outside, before any check of the open handle can catch it. A site without custom.ini now gets the warning with the manual fix instead; the restore only copies custom.ini from a backup that has one, so the site keeps its own. With nothing created, the helpers that removed and chowned created files are gone.
The read-back after a write stops one byte past the new content, so data the container appends in the meantime can't make it unbounded.
A missing custom.ini is now skipped with a warning and the manual fix, like a symlinked one, instead of being created, for the same reason as in the restore: PHP can't create it safely in a directory the site's container can swap. The v4.13.3 migrations already created it wherever conf.d existed, so this only leaves the sites they warned about. Every changed file now has a backup.
A site's php container can replace custom.ini with a FIFO after the caller's is_file() check, and a blocking fopen() for reading then waits for a writer before the regular-file check runs, which stalls the upgrade or the restore. open_container_file() now opens with O_NONBLOCK (fopen mode 'n'), rejects the FIFO as before, and switches a regular file's handle back to blocking.
On pull requests and develop the suite installed site-command's develop branch, so it never ran the changes under test. A path repository now installs this checkout. A local rclone alias named easyengine (EasyEngine's default rclone-path) lets the suite back up and restore.
The re-check migration turns upload progress off for a site whose only setting is in a CRLF [PATH=] section, puts the line at the top, keeps an explicit global On byte for byte and changes nothing on a second run. Restoring a backup without the line adds it again. A missing custom.ini is warned about and not created, by the migration and by the restore.
… ran again
The upgrade, backup and restore steps refuse to run outside CI unless EE_BEHAT_DESTRUCTIVE=1, so a local run doesn't re-run the migrations on a real host or back up to its real remote. The second migration run is now shown by its warning for the site without custom.ini, the restored site's check waits for the php-fpm reload like the others, the helpers' exit codes are checked, and the saved copies are removed at the end. The workflow sets RCLONE_CONFIG once for the job and installs only rclone; EasyEngine installs the archive tools itself.
A failure from opening or reading custom.ini (a hard-linked or oversized file, a swapped conf.d) reached the per-site catch, whose warning didn't say how to apply the setting by hand. It now ends with the same manual fix as the other warnings.
The before-upgrade and before-restore states relied on the site's php.ini leaving the directive at PHP's default On. Newer php.ini templates and images set it Off, which would hide the gap the scenarios exercise, so the fixtures now set it On in php.ini explicitly, as an older host could have, and the restore site gets its own before check.
…read, write and chown helpers
open_container_file() now refuses a symlink, FIFO, device or missing file before it opens anything, and checks that the opened file is the inode it saw. New get_container_file_contents(), put_container_file_contents(), check_plain_tree() and chown_container_tree() let every caller that touches a site's php config volume use the same checks. site_php_conf_dir() takes the directory below config/php.
… instead of rsync
rsync followed a php-fpm.d, php or conf.d directory that the site's php container had swapped for a symlink, so host root wrote the backed-up files wherever it pointed. Each backed-up file must now be a regular file, and is written over the site's existing regular file through a checked handle. Nothing is created; a file that can't be restored is kept under the backup dir with the manual fix.
…helpers
open_ini() checked only php.ini itself, so a symlinked php/ directory in the config volume redirected the rewrite. Reads are now capped at 1 MB.
update --php removed and re-created everything in the site's config volume while the new php container was running, so the container could redirect those deletes and writes through a symlink. The php container (the only one that mounts the volume) is now stopped first, the volume must hold only directories and regular files, the backup is a fresh copy, and the new config is in place before the site starts with the new PHP version. enable() gives www-data conf.d without following symlinks.
put_container_file_contents() reads first and opens the file for writing only when its content has to change, so an unchanged hard-linked or immutable file is no longer an error.
…php container
Restore used to create a backed-up file that the site lacked (e.g. a pool file added by hand). It is now written from inside the running php container, where a symlink the container made can't point at the host; existing files are still written in place through a checked handle. On macOS, where config/php isn't mounted, a missing file is skipped as before had no effect. The kept-file warning no longer suggests copying through config/php by hand.
…led update --php
The new php image is pulled before php stops, so the site is down only while its config is switched, and a failed pull changes nothing. A failure after that puts the old config back (with php stopped again), the old compose file and a running php container; enable() now throws instead of exiting so this also covers it. An older config backup of the same version is kept aside instead of removed. A failed restart after a refused update says so.
…nged
A refused write (php.ini changed meanwhile or hard-linked) left an unused backup behind; it is removed, and a failed write reports where the backup is.
… session ID settings
A CR-only php.ini was one line, so a match removed the whole file. Lines now end after every LF and after a CR not followed by LF.
… before a rollback
enable() can start a custom compose service that also mounts config_php before it fails; the rollback now refuses to touch the volume while one runs, as the update itself does.
This PR hardens EasyEngine’s PHP configuration handling against container-controlled symlinks during restore, PHP version updates, enablement, and migrations. It also includes the stacked session-configuration fixes from #519 and #520.
Changes:
Adds checked file-access helpers and safer restore and ownership operations.
Stops PHP during configuration switches and adds rollback handling.
Adds session-setting migrations and restore regression coverage with CI backup storage.
File
Description
src/helper/site-utils.php
Adds checked filesystem helpers and session-setting parsing.
src/helper/Site_Backup_Restore.php
Hardens PHP config restoration and preserves upload-progress protection.
src/helper/class-ee-site.php
Adds stopped-container config switching, rollback, and checked ownership changes.
strip_settings() treats physical lines as complete INI entries without tracking quoted values. A valid multiline entry such as session.sid_length = "26\n" loses only its first line, leaving a dangling quote and making the rewritten php.ini fail to parse; a directive-looking line inside another multiline value is also deleted even though it is not a setting. Please identify complete INI entries, preserving quoted-value contents and removing the entire deprecated entry, before overwriting the live configuration.
…ainer
The rsync-based restore copied the whole php-fpm.d tree; the checked restore only handled its top-level files. Walk the backup's php-fpm.d tree again (directories and regular files only, symlinks, FIFOs, devices and control-character names refused), create missing directories and files from inside the running php container one level at a time, relative to a parent that must be a real directory there, and apply the backed-up permission bits there too. Created entries take the owner of their parent directory, since the backup holds no owner; existing files are still overwritten in place through the checked helpers and keep their owner. Entries below a directory that can't be restored are kept with the other unrestorable files.
…d bound the backup walk
Each in-container create or chmod now runs as the owner of the parent directory (looked up first, behind the same real-directory check), so a symlink planted by a user who can write that directory can't make root change files that user couldn't; created entries belong to that owner without a chown, and directories get their mode from mkdir directly. A failed chmod after a successful create is reported as a mode that wasn't set instead of a failed create. php.ini and custom.ini are collected before php-fpm.d, the walk stops at 1000 entries (refused ones included) with one warning, modes are compared on the permission bits only, a macOS skip no longer reads the backed-up file first, and the kept-file hint names a directory only when one was the cause.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #519 and #520, merge after them. This branch is #520's head plus a merge of #519's head, so the diff below also shows their commits until they're merged; the commits of this PR are the ones after the merge commit.
What
Host-root code that reads or writes a site's php config volume no longer follows anything the site's php container put there:
ee site restore,ee site update --php,ee site enableand #519's php.ini migration all go through one set of checked helpers.Why
A site's
config/phpis a link to itsconfig_phpvolume, which the php container mounts at/usr/local/etc(as root, and as www-data after a PHP switch, which chowns the volume's top directory to www-data). The container can therefore replacephp/,php/conf.d,php-fpm.dor any file in them with a symlink, FIFO or device node. EasyEngine then followed it as root:restore_php_conf()ranrsync -aintoconfig/php/php-fpm.d/,config/php/php/php.iniandconfig/php/php/conf.d/custom.ini, so a swapped directory sent the backed-up files into whatever host directory it pointed at.update_php()removed everything in the volume and mirrored the new version's config into it while the new php container was already running, so a swap during that window redirected deletes and writes. A symlinkedcustom.inioreasyengine.confwas copied into the backup as a link and then read back through it into the new config, so the contents of a host file ended up in the site's config.open_ini()checked onlyphp.iniitself, not the directories above it.enable()recursively chownedconfig/php/php/conf.dwhile the container may be running.PHP has no
O_NOFOLLOW,openat()orO_PATH, and it resolves symlinks in user space beforeopen(), so a path check alone is always racy. #520 addedopen_container_file(), which opens once and checks the handle (the kernel's path of the open fd from/proc/self/fd,fstat()againstlstat(), one hard link for writes, non-blocking against FIFOs). This PR builds on it.How
site-utils.php:open_container_file()nowlstat()s the path first and refuses a missing file, a symlink, FIFO, device or directory without opening it, and the opened inode must be the one it saw.get_container_file_contents()andput_container_file_contents()are the path-level read and in-place write (owner, mode and inode kept; compared read-only first, so an unchanged file isn't opened for writing; an optional expected content refuses a file that changed since it was checked; a short write puts the old content back). Neither creates a file.check_plain_tree()refuses a tree that holds anything but directories and regular files;chown_container_tree()chowns directories and regular files only, withlchown(), re-checking each directory's real path.site_php_conf_dir()takes the directory belowconfig/php(defaultphp/conf.d, as before).restore_php_conf_files()replaces the threersynccalls and restores the same set: the wholephp-fpm.d/tree including its subfolders,php/php.iniandphp/conf.d/custom.ini. The backup came from the same volume, so it may hold only directories and regular files: symlinks, FIFOs, devices and names with control characters are refused with a warning (unzip already strips../and leading/from entry names, so those land inside the backup dir).php.iniandcustom.iniare collected first; thephp-fpm.dwalk stops at 1000 entries with one warning, and nothing deeper than 16 levels is restored. An existing file is written over the site's regular file in place through a checked handle. A directory or file the site lacks (e.g. a pool file or subfolder added by hand, restored into a fresh site) is created from inside the running php container, one level at a time, parents first: a firstdocker execchanges into the parent, checks that it is a real directory there (cd -P+pwd -P, no symlink in its path) and reads its owner; a second one, run as that owner (-u uid:gid), does the same check and creates the entry relative to it (mkdir -m, orcat >with noclobber, thenchmod). A symlink the container made can't point at the host, a directory swapped after the check can't redirect the write, and a user who can write the parent can't get root to change anything that user couldn't. The backed-up permission bits (rwx; setuid/setgid/sticky aren't taken from the backup) are applied the same way, to created entries and to existing ones whose bits differ. A created entry belongs to the owner and group of the directory it is created in (the backup holds no owner: 7-Zip's zip stores none, and unzip extracts as root); an existing one keeps its own, and its mode is only changed when that parent's owner may change it (otherwise warned). A directory or file that can't be restored (swapped for a symlink, not a plain directory or regular file in the site, or missing while php isn't running) is warned about; its files, and those below it, are kept under.backup/<site>-php-conf-not-restored-<time>/. On macOS, whereconfig/phpisn't mounted, a missing directory or file is skipped and no mode is set, which is what rsync's copy amounted to there. A backup whosephpdirectory is itself a link (some 7-Zip versions storeconfig/phpthat way) is refused.keep_session_upload_progress_off()(fix(restore): keep session upload progress off after restoring an older backup #520) uses the same helpers.update --php: the new image is pulled first (docker-compose pull php), while the site still runs. Then the php container, the only one that mounts the volume, is stopped; a running container that still mounts the volume (e.g. from a custom compose file) stops the update; the volume must hold only directories and regular files, or the update stops with "Nothing was changed" and the container is started again. The backup of the old config is a fresh copy (an older backup of the same version could hold a link that the mirror would write through; it is kept aside with a timestamp). The new version's config is put in place before the site starts on the new PHP version, so the new PHP never runs with the old version's config. A failure after the pull is rolled back: php is stopped again ifenable()had started it, the old config is copied back from the backup (only while no other container mounts the volume), the old compose file is written and php is started on the old image (enable()is now called so that it throws instead of exiting).enable(): conf.d is given to www-data withchown_container_tree(); a failure is a warning, as the site can run without it.php.inithrough the helpers (capped at 1 MB); a swappedphp.iniorphp/directory is now a per-site warning instead of a silent skip, a backup it no longer needs is removed, and a CR-onlyphp.iniis split into lines (it used to be one line, so a match emptied the file).Behaviour changes
ee site restoreoverwrites existing files' content in place and creates missing directories and files (including subfolders ofphp-fpm.d, at any depth) from inside the running php container. Restored: content, directory structure, permission bits (rwx). Not restored: owner (created entries get their parent directory's owner, existing ones keep theirs; rsync made everything root-owned, since unzip extracts as root), setuid/setgid/sticky bits and mtime; the mode of an existing entry the parent directory's owner can't change (e.g. a root-owned file in a www-data directory) is left as it is, with a warning. Missing entries need the php container running; without it they are kept under.backup/with a warning, and modes are not set (also warned). Entries in the site that the backup lacks are left alone, as with rsync (no--delete).ee site update --phpstops the php container for the duration of the config switch (after pulling the image) and refuses a config volume that holds symlinks, FIFOs or devices. The image's/usr/local/etcand the template archives have none.chown -R www-data: config/phponly ever changed the volume's top directory (chown doesn't descend through a symlink named on the command line); that's unchanged.What is left
chown_container_tree()(used byenable(), where the container may be running) re-checks each directory's real path beforelchown(), but a directory swapped between that check and thelchown()can still redirect it; there is nofchownat()in PHP.lstat()and theopen()insideopen_container_file()(microseconds), a symlink, FIFO or device renamed into place is still opened, non-blocking, and closed again before any read or write; opening some devices has side effects. Only an open withO_NOFOLLOW/openat(), which PHP lacks, or a write from inside a container that mounts only the volume avoids that. On macOSconfig/phpis a host directory and onlycustom.iniis bind-mounted, so the container can't swap anything there.7z u conf.zip phpfollows symlinks inside the volume (it archives the target's content) and blocks on a FIFO; the restore refuses links but can't tell such content from a real file.chmodfollows a symlink swapped in after its check; it runs as the parent directory's owner, so it can't change anything that owner couldn't, and it stays inside the container.Testing
check_plain_tree()andchown_container_tree()(symlink target untouched).conf.dwith a symlink to another directory: the old file-only check plus path write wrote into the other directory 286 (7.4) / 361 (8.5) times; the helpers 0 times.php.inichanged and rolled back; symlinkedphp/and symlinkedphp.inirefused with a warning, the target untouched. fix(templates): drop deprecated session ID ini settings from the PHP 8.4 and 8.5 configs #519's head writes through both.docker execthat runs the same in-container script on the host with the volume as its base (so a symlink there really points outside): a backup with subfolders three levels deep, an empty directory and a name starting with-restores fully into a site that lacks them (php/conf.dmissing too), with the backed-up modes, created entries owned like their parent and existing files keeping their owner, and a second restore needs no in-container step; in a www-data directory, created entries belong to www-data and a root-owned file's mode is left alone with a warning; aphp-fpm.dwith 1200 files still getsphp.iniandcustom.inirestored with one warning for the limit, and a tree 18 levels deep stops at 16; a backup holding a symlinked file, a symlinked directory, a relative symlink, a FIFO, a control-character name or a symlinkedphp/conf.dhas those refused and the rest restored; a crafted zip with a symlink entry, a file through it,../and absolute entries, extracted withunzip, writes nothing outside and its plain entry is restored; a site whosephp-fpm.d/poolorpool/subis a symlink to an outside directory, or whosewww.confis one, gets a warning, the files kept under.backup/and the outside directory untouched; the in-container step refuses a symlinked parent,.., absolute paths, other roots and setuid modes on its own; with php stopped, existing files are restored and missing ones kept; two races, 300 restores each: another process keeps swappingphp-fpm.d/poolbetween the directory and a symlink to an outside directory while restore creates files in it, and a www-data process keeps swappingpool/m.confwith a symlink to a root-owned outside file while restore rewrites it and sets its mode: both show restores that went through and restores that were refused, and 0 changes outside (content, mode, owner).develop: with develop's site-command, a restore with a swappedconf.dwrites into the host directory andupdate --phpcopies a host file into the site'scustom.inithrough a symlinkedcustom.ini; with this branch the host directory is untouched, the restore warns,update --phprefuses with the php container running again on the old version, a clean restore brings back the backed-upcustom.iniwith upload progress Off, a restore recreates aneasyengine.confmissing in the site from inside the container, a restore recreates a removedphp-fpm.d/extra/sub/deep/tree with its files, the backed-up modes (750/711/755/600) and www-data as owner like its parent, a restore withphp-fpm.d/extra(orextra/sub) swapped for a symlink to a host directory refuses it, keeps its files and leaves the host directory untouched, a backup archive with symlink entries,../and absolute entries added writes nothing outside the site and restores its plain entries, anupdate --phpthat fails after the switch began (template archive removed) is rolled back to the old config, compose file and PHP version, and a cleanupdate --phpserves the new PHP version withcustom.inicarried over and the old config backed up. Behat passes.Release order
site-type-wp and site-type-php use
get_container_file_contents()/put_container_file_contents()from this PR when they exist and fall back to a path check otherwise, so they work with an older site-command, but they get the race-safe behaviour only together with this one.