diff --git a/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php index 1b3740f..6fdb4ed 100644 --- a/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php +++ b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php @@ -7,11 +7,16 @@ use EE\Model\Site; use function EE\Auth\Utils\generate_site_auth_files; use function EE\Auth\Utils\generate_site_whitelist; +use function EE\Auth\Utils\get_wildcard_staging_dir; +use function EE\Auth\Utils\proxy_has_acl_template; class RegenerateSiteAuthFiles extends Base { private $sites; + /** @var string|null Backup of `htpasswd/` and `vhost.d/*_acl` taken by up(). */ + private $auth_backup; + public function __construct() { parent::__construct(); @@ -36,21 +41,166 @@ public function up() { return; } + $this->auth_backup = $this->backup_auth_files(); + + $new_template = proxy_has_acl_template(); + $stage = get_wildcard_staging_dir(); + $this->fs->remove( $stage ); + + // The old template applies `_wildcard.X` to every subdomain of X, even on a container event without a reload, so those files wait outside its mounts. + if ( ! $new_template ) { + $this->fs->mkdir( [ $stage, "$stage/htpasswd", "$stage/vhost.d" ], 0700 ); + } + foreach ( $this->sites as $site ) { try { - generate_site_auth_files( $site->site_url, $site ); - generate_site_whitelist( $site->site_url, $site ); + generate_site_auth_files( $site->site_url, $site, [], $new_template ? '' : $stage ); + generate_site_whitelist( $site->site_url, $site, [], $new_template ? '' : $stage ); } catch ( \Throwable $e ) { EE::warning( sprintf( 'Could not regenerate the auth files of %s: %s', $site->site_url, $e->getMessage() ) ); } } - \EE\Site\Utils\reload_global_nginx_proxy(); + if ( $new_template ) { + \EE\Site\Utils\reload_global_nginx_proxy(); + } else { + // Promoted by the after_docker_image_migration hook, or on a later run, once the new proxy runs. + EE::debug( "Staged the wildcard auth files in $stage; nginx-proxy runs the old template." ); + } } /** - * Not reverted. The files need the nginx-proxy image of the same release: the previous image also applies `_wildcard.*` files to sibling sites. + * Discards the staged wildcard files and restores the files saved by up() exactly, including removing files it added. + * + * @throws \Exception */ public function down() { + + $this->fs->remove( get_wildcard_staging_dir() ); + + if ( empty( $this->auth_backup ) || ! is_dir( $this->auth_backup ) ) { + EE::debug( 'No site auth files backup to restore.' ); + + return; + } + + foreach ( $this->get_auth_dirs() as $name => $dir ) { + $this->restore_dir( $this->auth_backup . '/' . $name, $dir, 'vhost.d' === $name ); + } + + if ( 'running' === \EE_DOCKER::container_status( EE_PROXY_TYPE ) ) { + \EE\Site\Utils\reload_global_nginx_proxy(); + } + + $this->fs->remove( $this->auth_backup ); + EE::debug( 'Restored the site auth files from ' . $this->auth_backup ); + $this->auth_backup = null; + } + + /** + * @return array Backup subdirectory name => proxy directory. + */ + private function get_auth_dirs() { + + return [ + 'htpasswd' => EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', + 'vhost.d' => EE_ROOT_DIR . '/services/nginx-proxy/vhost.d', + ]; + } + + /** + * Only `*_acl` files of vhost.d are written by auth-command. + * + * @param string $file File name. + * @param bool $acl_only Whether only ACL files are handled. + * + * @return bool + */ + private function is_handled_file( string $file, bool $acl_only ) { + + return ! $acl_only || '_acl' === substr( $file, -4 ); + } + + /** + * Copies `htpasswd/` and `vhost.d/*_acl` to a new directory under EE_BACKUP_DIR. + * + * Kept after a successful upgrade: downgrading needs these files, the old nginx-proxy leaks the new `_wildcard.X` files to sibling sites. + * + * @return string Backup directory. + * @throws \Exception + */ + private function backup_auth_files() { + + $backup = EE_BACKUP_DIR . '/auth-migration-' . date( 'Ymd-His' ); + if ( file_exists( $backup ) ) { + $backup .= '-' . getmypid(); + } + + try { + // Holds password hashes. + $this->fs->mkdir( $backup, 0700 ); + foreach ( $this->get_auth_dirs() as $name => $dir ) { + $this->fs->mkdir( $backup . '/' . $name, 0700 ); + if ( ! is_dir( $dir ) ) { + continue; + } + foreach ( scandir( $dir ) as $file ) { + if ( ! is_file( $dir . '/' . $file ) || ! $this->is_handled_file( $file, 'vhost.d' === $name ) ) { + continue; + } + $this->copy_file( $dir . '/' . $file, $backup . '/' . $name . '/' . $file ); + } + } + } catch ( \Throwable $e ) { + $this->fs->remove( $backup ); + throw new \Exception( 'Could not back up the site auth files: ' . $e->getMessage() ); + } + + EE::debug( "Backed up the site auth files to $backup" ); + + return $backup; + } + + /** + * Makes the handled files of $dir identical to $backup_dir. + * + * @param string $backup_dir Backup directory. + * @param string $dir Proxy directory. + * @param bool $acl_only Whether only ACL files are handled. + */ + private function restore_dir( string $backup_dir, string $dir, bool $acl_only ) { + + if ( ! is_dir( $dir ) || ! is_dir( $backup_dir ) ) { + return; + } + + foreach ( scandir( $dir ) as $file ) { + if ( is_file( $dir . '/' . $file ) && $this->is_handled_file( $file, $acl_only ) && ! file_exists( $backup_dir . '/' . $file ) ) { + $this->fs->remove( $dir . '/' . $file ); + } + } + + foreach ( scandir( $backup_dir ) as $file ) { + if ( ! is_file( $backup_dir . '/' . $file ) ) { + continue; + } + // Renamed into place so the proxy never reads a partial file. + $tmp = $dir . '/.' . $file . '.restore'; + $this->copy_file( $backup_dir . '/' . $file, $tmp ); + $this->fs->rename( $tmp, $dir . '/' . $file, true ); + } + } + + /** + * Copies a file with its mode and modification time. + * + * @param string $source Source path. + * @param string $target Target path. + */ + private function copy_file( string $source, string $target ) { + + $this->fs->copy( $source, $target, true ); + $this->fs->chmod( $target, fileperms( $source ) & 0777 ); + $this->fs->touch( $target, filemtime( $source ) ); } } diff --git a/src/auth-utils.php b/src/auth-utils.php index 420abc0..4ed908f 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -6,6 +6,7 @@ use EE; use EE\Model\Auth; use EE\Model\Option; +use EE\Model\Site; use EE\Model\Whitelist; use Symfony\Component\Filesystem\Filesystem; use function EE\Service\Utils\ensure_global_network_initialized; @@ -158,30 +159,32 @@ function remove_proxy_file( string $dir, string $name ): bool { } /** - * Copies a file inside a proxy directory to other names in the same directory. + * Copies a file inside a proxy directory to other names in the same directory, or in $target_dir. * - * @param string $dir Directory path. - * @param string $source Source file name. - * @param array $targets Target file names. + * @param string $dir Directory path. + * @param string $source Source file name. + * @param array $targets Target file names. + * @param string $target_dir Target directory, $dir if empty. */ -function copy_proxy_file( string $dir, string $source, array $targets ) { +function copy_proxy_file( string $dir, string $source, array $targets, string $target_dir = '' ) { - $fs = new Filesystem(); - $mode = fileperms( $dir . '/' . $source ) & 0777; + $fs = new Filesystem(); + $mode = fileperms( $dir . '/' . $source ) & 0777; + $target_dir = '' === $target_dir ? $dir : $target_dir; foreach ( $targets as $target ) { - if ( ! is_proxy_file_name( $target ) || $target === $source ) { + if ( ! is_proxy_file_name( $target ) || ( $target === $source && $target_dir === $dir ) ) { continue; } // Built under a name no host matches, then renamed, so the proxy never reads a partial copy. $tmp = '.' . $target . '.tmp'; try { - $fs->copy( $dir . '/' . $source, $dir . '/' . $tmp, true ); + $fs->copy( $dir . '/' . $source, $target_dir . '/' . $tmp, true ); // Don't depend on the umask: nginx workers read these files. - $fs->chmod( $dir . '/' . $tmp, $mode ); - $fs->rename( $dir . '/' . $tmp, $dir . '/' . $target, true ); + $fs->chmod( $target_dir . '/' . $tmp, $mode ); + $fs->rename( $target_dir . '/' . $tmp, $target_dir . '/' . $target, true ); } catch ( \Exception $e ) { - remove_proxy_file( $dir, $tmp ); + remove_proxy_file( $target_dir, $tmp ); EE::warning( sprintf( 'Could not copy %s to %s, so it was left unchanged.', $source, $target ) ); } } @@ -215,14 +218,21 @@ function remove_auth_files( array $domains ): bool { * @param string $site_url URL of site. * @param \EE\Model\Site|null $site_data Site model. * @param array $extra_aliases Alias domains not saved on the site yet, e.g. ones about to be added. + * @param string $stage_dir If set, `_wildcard.*` files are written to its `htpasswd/` instead of the proxy. * * @throws \Exception */ -function generate_site_auth_files( string $site_url, $site_data = null, array $extra_aliases = [] ) { +function generate_site_auth_files( string $site_url, $site_data = null, array $extra_aliases = [], string $stage_dir = '' ) { $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; $domains = get_site_auth_domains( $site_url, $site_data, $extra_aliases ); $site_auths = Auth::where( 'site_url', $site_url ); + $staged = '' === $stage_dir ? [] : array_filter( $domains, __NAMESPACE__ . '\is_wildcard_auth_name' ); + + // A staged name must not stay live either, its staged copy replaces it. + foreach ( $staged as $domain ) { + remove_proxy_file( $dir, $domain ); + } // Without site entries the proxy falls back to the global `default` file. if ( empty( $site_auths ) ) { @@ -237,7 +247,8 @@ function generate_site_auth_files( string $site_url, $site_data = null, array $e // If it can't be rewritten (e.g. the proxy is stopped during an upgrade), still spread the existing file to the other domains. if ( write_htpasswd_file( $source, array_merge( Auth::get_global_auths(), $site_auths ) ) || is_file( $dir . '/' . $source ) ) { - copy_proxy_file( $dir, $source, $domains ); + copy_proxy_file( $dir, $source, array_diff( $domains, $staged ) ); + copy_proxy_file( $dir, $source, $staged, $stage_dir . '/htpasswd' ); } } @@ -380,21 +391,24 @@ function get_site_whitelist_ips( string $site_url ): array { * @param string $site_url URL of site, `default` for global. * @param \EE\Model\Site|null $site_data Site model. * @param array $extra_aliases Alias domains not saved on the site yet, e.g. ones about to be added. + * @param string $stage_dir If set, `_wildcard.*` files are written to its `vhost.d/` instead of the proxy. * * @throws \Exception */ -function generate_site_whitelist( string $site_url, $site_data = null, array $extra_aliases = [] ) { +function generate_site_whitelist( string $site_url, $site_data = null, array $extra_aliases = [], string $stage_dir = '' ) { $dir = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d'; $domains = get_site_auth_domains( $site_url, $site_data, $extra_aliases ); $ips = get_site_whitelist_ips( $site_url ); foreach ( $domains as $domain ) { + $staged = '' !== $stage_dir && is_wildcard_auth_name( $domain ); // Without site entries the proxy falls back to `default_acl`. - if ( empty( $ips ) ) { + if ( empty( $ips ) || $staged ) { remove_proxy_file( $dir, $domain . '_acl' ); - } else { - put_ips_to_file( $dir . '/' . $domain . '_acl', $ips ); + } + if ( ! empty( $ips ) ) { + put_ips_to_file( ( $staged ? $stage_dir . '/vhost.d' : $dir ) . '/' . $domain . '_acl', $ips ); } } } @@ -418,3 +432,116 @@ function put_ips_to_file( string $file, array $ips ) { $file_content .= 'deny all;'; ( new Filesystem() )->dumpFile( $file, $file_content ); } + +/** + * Checks whether a htpasswd/ACL file name is a `*.X` one. + * + * @param string $name File name as returned by get_site_auth_domains(). + * + * @return bool + */ +function is_wildcard_auth_name( string $name ): bool { + + return 0 === strpos( $name, '_wildcard.' ); +} + +/** + * Directory, outside the proxy's mounts, where the auth migration stages `_wildcard.*` files while the old nginx-proxy template runs. + * + * @return string + */ +function get_wildcard_staging_dir(): string { + + // Site names always contain a dot, so no per-site `.backup//` dir can clash with it. + return EE_BACKUP_DIR . '/auth-wildcard-staging'; +} + +/** + * Checks whether the running nginx-proxy has the template that applies `_wildcard.X` files only to `*.X` hosts. + * + * @return bool False when the proxy isn't running or runs an older template. + */ +function proxy_has_acl_template(): bool { + + if ( 'running' !== \EE_DOCKER::container_status( EE_PROXY_TYPE ) ) { + EE::debug( 'nginx-proxy is not running: treating its template as the old one.' ); + + return false; + } + + $check = EE::launch( sprintf( 'docker exec %s grep -c %s /app/nginx.tmpl', EE_PROXY_TYPE, escapeshellarg( 'define "acl"' ) ) ); + $new = 0 === $check->return_code && (int) trim( $check->stdout ) > 0; + EE::debug( 'nginx-proxy template: ' . ( $new ? 'new (has the acl block)' : 'old (no acl block)' ) ); + + return $new; +} + +/** + * Checks whether a staged file matches the site's own live file: both absent, or both with the same content. + * + * @param string $live Live file path. + * @param string $staged Staged file path. + * + * @return bool + */ +function staged_file_is_current( string $live, string $staged ): bool { + + if ( ! is_file( $live ) || ! is_file( $staged ) ) { + return is_file( $live ) === is_file( $staged ); + } + + return file_get_contents( $live ) === file_get_contents( $staged ); +} + +/** + * Moves the `_wildcard.*` files staged by the auth migration into the proxy once it runs the new template, then reloads it. + * + * @return bool Whether the staged files were promoted. + * @throws \Exception + */ +function promote_staged_wildcard_files(): bool { + + $stage = get_wildcard_staging_dir(); + if ( ! is_dir( $stage ) || ! proxy_has_acl_template() ) { + return false; + } + + $ht = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; + $vd = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d'; + + foreach ( Site::all() as $site ) { + $url = $site->site_url; + $names = array_filter( get_site_auth_domains( $url, $site ), __NAMESPACE__ . '\is_wildcard_auth_name' ); + if ( empty( $names ) ) { + continue; + } + + $current = true; + foreach ( $names as $name ) { + $current = $current && staged_file_is_current( "$ht/$url", "$stage/htpasswd/$name" ) && staged_file_is_current( "{$vd}/{$url}_acl", "$stage/vhost.d/{$name}_acl" ); + } + + if ( ! $current ) { + // Auth changed since staging, e.g. by the older ee after an interrupted upgrade. + EE::debug( "Staged wildcard auth files of $url are outdated, regenerating them." ); + add_site_auth_files( $url, $site, [] ); + continue; + } + + foreach ( $names as $name ) { + if ( is_file( "$stage/htpasswd/$name" ) ) { + copy_proxy_file( "$stage/htpasswd", $name, [ $name ], $ht ); + } + if ( is_file( "$stage/vhost.d/{$name}_acl" ) ) { + copy_proxy_file( "$stage/vhost.d", $name . '_acl', [ $name . '_acl' ], $vd ); + } + } + } + + // Files of sites deleted since staging are dropped with it. + ( new Filesystem() )->remove( $stage ); + \EE\Site\Utils\reload_global_nginx_proxy(); + EE::debug( 'Promoted the staged wildcard auth files.' ); + + return true; +} diff --git a/src/helper/hooks.php b/src/helper/hooks.php index 4bcc2b6..c28c4c2 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -10,6 +10,8 @@ use function EE\Auth\Utils\add_site_auth_files; use function EE\Auth\Utils\get_alias_auth_domains; use function EE\Auth\Utils\get_site_auth_domains; +use function EE\Auth\Utils\get_wildcard_staging_dir; +use function EE\Auth\Utils\promote_staged_wildcard_files; use function EE\Auth\Utils\remove_auth_files; use function EE\Auth\Utils\site_auth_files_missing; @@ -115,7 +117,46 @@ function remove_auth_on_alias_domains_update_failure( $site_url, $domains_to_add } } +/** + * Hook to apply the wildcard auth files staged by the auth migration once the new nginx-proxy runs. + */ +function promote_staged_wildcard_auth() { + + if ( ! is_dir( get_wildcard_staging_dir() ) ) { + return; + } + + try { + promote_staged_wildcard_files(); + } catch ( \Throwable $e ) { + // Until promoted, subsites stay unprotected as before the upgrade; retried on the next run. + EE::warning( 'Could not apply the staged wildcard auth files: ' . $e->getMessage() ); + } +} + +/** + * Hook to promote staged wildcard auth files left by an interrupted or failed upgrade, once per run. + */ +function maybe_promote_staged_wildcard_auth() { + + static $checked = false; + + if ( $checked || ! defined( 'EE_PROXY_TYPE' ) || ! is_dir( get_wildcard_staging_dir() ) ) { + return; + } + $checked = true; + + // Not while a migration is pending: its image migration may still bring back the old proxy. + if ( EE_VERSION !== \EE\Model\Option::get( 'version' ) ) { + return; + } + + promote_staged_wildcard_auth(); +} + EE::add_hook( 'site_cleanup', 'cleanup_auth_and_whitelist' ); +EE::add_hook( 'after_docker_image_migration', 'promote_staged_wildcard_auth' ); +EE::add_hook( 'find_command_to_run_pre', 'maybe_promote_staged_wildcard_auth' ); EE::add_hook( 'site_alias_domains_before_update', 'add_auth_before_alias_domains_update' ); EE::add_hook( 'site_alias_domains_updated', 'update_auth_on_alias_domains_change' ); EE::add_hook( 'site_alias_domains_update_failed', 'remove_auth_on_alias_domains_update_failure' );