From e5ec1c90d4823b27ee9853f90f367d081ecfb101 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:27:00 +0000 Subject: [PATCH 1/7] feat(site): fire hook after alias domains update Fire site_alias_domains_updated with the site URL and the added and removed alias domains once the alias change has been applied, so packages that keep per-domain config (e.g. auth-command's htpasswd and whitelist files) can sync it. --- src/helper/class-ee-site.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 29be462d..30406739 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -653,6 +653,17 @@ protected function update_alias_domains( $args, $assoc_args ) { ]; $this->update_proxy_cache( $args, $assoc_args ); } + + /** + * Execute after the alias domains of a site have been updated. + * Note: This can be used by package commands to sync their per-domain config. + * + * @param string $site_url Url of site whose alias domains changed. + * @param array $domains_to_add Alias domains that were added. + * @param array $domains_to_delete Alias domains that were removed. + */ + \EE::do_hook( 'site_alias_domains_updated', $this->site_data['site_url'], array_values( $domains_to_add ), array_values( $domains_to_delete ) ); + delem_log( 'site alias domains update end' ); } From c3e725f85d2d42241c4603fe35741543013ada45 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:06:55 +0000 Subject: [PATCH 2/7] fix(site): drop blank entries from alias domain lists `--add-alias-domains='b.com,'` stored an empty alias domain, and blank entries also reached the alias domain hooks, where they turned into file names. The existing, added and deleted lists are now trimmed and blank entries dropped before any processing, an update with nothing left to add or delete is refused, and the hook gets re-indexed arrays. --- src/helper/class-ee-site.php | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 30406739..02bdf519 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -529,25 +529,23 @@ protected function update_alias_domains( $args, $assoc_args ) { $array_data = (array) $this->site_data; $this->site_data = reset( $array_data ); - // Validate data. - $existing_alias_domains = []; - $domains_to_add = []; - $domains_to_delete = []; + // Drop blanks so that e.g. `b.com,` never stores an empty alias domain. + $split_domains = function ( $domains ) { + return array_values( array_filter( array_map( 'trim', explode( ',', (string) $domains ) ), 'strlen' ) ); + }; - if ( ! empty( $this->site_data['alias_domains'] ) ) { - $existing_alias_domains = explode( ',', $this->site_data['alias_domains'] ); - } - if ( ! empty( $add_domains ) ) { - $domains_to_add = explode( ',', $add_domains ); - } - if ( ! empty( $delete_domains ) ) { - $domains_to_delete = explode( ',', $delete_domains ); + $existing_alias_domains = $split_domains( $this->site_data['alias_domains'] ); + $domains_to_add = $split_domains( $add_domains ); + $domains_to_delete = $split_domains( $delete_domains ); + + if ( empty( $domains_to_add ) && empty( $domains_to_delete ) ) { + EE::error( 'Please provide at least one alias domain to add or delete.' ); } $already_added_domains = array_intersect( $existing_alias_domains, $domains_to_add ); - $domains_to_add = array_diff( $domains_to_add, $existing_alias_domains ); + $domains_to_add = array_values( array_diff( $domains_to_add, $existing_alias_domains ) ); - if ( empty( $domains_to_add ) && $add_domains ) { + if ( empty( $domains_to_add ) && ! empty( $already_added_domains ) ) { $already_added_domains = implode( ',', $already_added_domains ); EE::error( "Alias domains: $already_added_domains is/are already present on the site." ); } @@ -662,7 +660,7 @@ protected function update_alias_domains( $args, $assoc_args ) { * @param array $domains_to_add Alias domains that were added. * @param array $domains_to_delete Alias domains that were removed. */ - \EE::do_hook( 'site_alias_domains_updated', $this->site_data['site_url'], array_values( $domains_to_add ), array_values( $domains_to_delete ) ); + \EE::do_hook( 'site_alias_domains_updated', $this->site_data['site_url'], $domains_to_add, $domains_to_delete ); delem_log( 'site alias domains update end' ); } From c4cd6a9ace2bbff98ce93ac7eb1a965d4fe7300f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:07:37 +0000 Subject: [PATCH 3/7] feat(site): fire hooks before an alias domains update and when it fails `site_alias_domains_updated` fires only after the new VIRTUAL_HOST has been rendered and the containers are up, so per-domain proxy config added from it (like auth-command's htpasswd and ACL files) arrives after the new alias domains are already served. `site_alias_domains_before_update` ($site_url, $domains_to_add) now fires once the input is validated and before docker-compose.yml is dumped. `site_alias_domains_update_failed` ($site_url, $domains_to_add) fires when the update is aborted after that point: when a before-update callback or dumping the compose file throws, or when the Let's Encrypt renewal fails and the revert has restored the old containers. The site then still has its old alias domains in the database. The flag is set before the before-update hook runs, so a callback that throws still gets the failure hook. --- src/helper/class-ee-site.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 02bdf519..45295ecc 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -522,6 +522,7 @@ protected function update_alias_domains( $args, $assoc_args ) { $add_domains = get_flag_value( $assoc_args, 'add-alias-domains', false ); $delete_domains = get_flag_value( $assoc_args, 'delete-alias-domains', false ); + $pre_hook_fired = false; try { @@ -581,6 +582,18 @@ protected function update_alias_domains( $args, $assoc_args ) { $final_alias_domains = array_merge( $existing_alias_domains, $domains_to_add ); $final_alias_domains = array_diff( $final_alias_domains, $domains_to_delete ); + // Set before firing, so a callback that throws still gets the failure hook to undo its partial work. + $pre_hook_fired = true; + + /** + * Execute before the new alias domains of a site are served by the proxy. + * Note: This can be used by package commands to set up per-domain config the proxy needs from the first request. + * + * @param string $site_url Url of site whose alias domains change. + * @param array $domains_to_add Alias domains that are being added. + */ + \EE::do_hook( 'site_alias_domains_before_update', $this->site_data['site_url'], $domains_to_add ); + $this->site_data['alias_domains'] = implode( ',', $final_alias_domains ); $is_ssl = $this->site_data['site_ssl'] ? true : false; $preferred_ssl_challenge = get_preferred_ssl_challenge( get_domains_of_site( $this->site_data['site_url'] ) ); @@ -589,6 +602,16 @@ protected function update_alias_domains( $args, $assoc_args ) { $this->dump_docker_compose_yml( [ 'nohttps' => $nohttps ] ); \EE_DOCKER::docker_compose_up( $this->site_data['site_fs_path'], [ 'nginx' ] ); } catch ( \Exception $e ) { + if ( $pre_hook_fired ) { + /** + * Execute when an alias domains update is aborted after `site_alias_domains_before_update`. + * Note: The site keeps its old alias domains, so this can be used to undo what was set up for the new ones. + * + * @param string $site_url Url of site whose alias domains update failed. + * @param array $domains_to_add Alias domains that were not added after all. + */ + \EE::do_hook( 'site_alias_domains_update_failed', $site->site_url, $domains_to_add ); + } EE::error( $e->getMessage() ); } @@ -618,6 +641,7 @@ protected function update_alias_domains( $args, $assoc_args ) { } catch ( \Exception $e ) { EE::warning( 'Certificate could not be issued. Reverting back to original state.' ); $this->enable( [ $this->site_data['site_url'] ], [ 'refresh' => 'true' ] ); + \EE::do_hook( 'site_alias_domains_update_failed', $site->site_url, $domains_to_add ); EE::error( $e->getMessage() ); } } elseif ( 'custom' === $this->site_data['site_ssl'] ) { From 7aff63d8f7870ef8eaa3ed0260334ee1342f176c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:08:28 +0000 Subject: [PATCH 4/7] refactor(site): leave auth cleanup on site delete to auth-command site-command no longer removes `htpasswd/` or the site's auth and whitelist rows in delete_site(). auth-command's `site_cleanup` hook, which runs just before, removes them along with the `_wildcard.`, alias and `_acl` files, so the copy here was a duplicate. Requires auth-command with the site_cleanup hook autoloaded (EasyEngine/auth-command#57). The two must ship in the same core release, or site delete leaves the site's auth files and rows behind. --- src/helper/class-ee-site.php | 43 ++++++------------------------------ 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 45295ecc..5234342e 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -8,8 +8,6 @@ use EE\Model\Cron; use EE\Model\Site; use EE\Model\Option; -use EE\Model\Auth; -use EE\Model\Whitelist; use Symfony\Component\Filesystem\Filesystem; use function EE\Site\Cloner\Utils\check_site_access; use function EE\Site\Cloner\Utils\copy_site_db; @@ -350,45 +348,18 @@ protected function delete_site( $level, $site_url, $site_fs_path, $db_data = [] if ( $level > 4 ) { if ( $this->site_data['site_ssl'] ) { \EE::log( 'Removing ssl certs and other config files.' ); - $crt_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.crt"; - $key_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.key"; - $pem_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.chain.pem"; - $conf_certs = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/certs/$site_url"; - $conf_var = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/var/$site_url"; - $htpasswd_file = EE_ROOT_DIR . "/services/nginx-proxy/htpasswd/$site_url"; - - $delete_files = [ $conf_certs, $conf_var, $crt_file, $key_file, $pem_file, $htpasswd_file ]; - try { - $this->fs->remove( $delete_files ); - } catch ( \Exception $e ) { - \EE::warning( $e ); - } - } + $crt_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.crt"; + $key_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.key"; + $pem_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.chain.pem"; + $conf_certs = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/certs/$site_url"; + $conf_var = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/var/$site_url"; - $site_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $site_url; - if ( $this->fs->exists( $site_auth_file ) ) { + $delete_files = [ $conf_certs, $conf_var, $crt_file, $key_file, $pem_file ]; try { - $this->fs->remove( $site_auth_file ); + $this->fs->remove( $delete_files ); } catch ( \Exception $e ) { \EE::warning( $e ); } - reload_global_nginx_proxy(); - } - - $whitelists = Whitelist::where( [ - 'site_url' => $site_url, - ] ); - - foreach ( $whitelists as $whitelist ) { - $whitelist->delete(); - } - - $auths = Auth::where( [ - 'site_url' => $site_url, - ] ); - - foreach ( $auths as $auth ) { - $auth->delete(); } if ( Site::find( $site_url )->delete() ) { From 52a5f7aa086c18e5336875664f877fc317bbbb98 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:50:35 +0000 Subject: [PATCH 5/7] fix(site): drop blank alias domains on html site create `ee site create --type=html --alias-domains='a.com,,b.com,'` stored empty alias domains, which ended up in VIRTUAL_HOST and the nginx server_name. The trim-and-drop-blanks split used on update is now `split_alias_domains()` in the site utils and is also used on create. A flag passed without a value no longer becomes the alias domain `1`. --- src/helper/class-ee-site.php | 11 ++++------- src/helper/site-utils.php | 17 +++++++++++++++++ src/site-type/HTML.php | 17 ++++------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 5234342e..1b814fd9 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -26,6 +26,7 @@ use function EE\Site\Utils\get_site_info; use function EE\Site\Utils\reload_global_nginx_proxy; use function EE\Site\Utils\get_parent_of_alias; +use function EE\Site\Utils\split_alias_domains; /** * Base class for Site command @@ -502,13 +503,9 @@ protected function update_alias_domains( $args, $assoc_args ) { $this->site_data = reset( $array_data ); // Drop blanks so that e.g. `b.com,` never stores an empty alias domain. - $split_domains = function ( $domains ) { - return array_values( array_filter( array_map( 'trim', explode( ',', (string) $domains ) ), 'strlen' ) ); - }; - - $existing_alias_domains = $split_domains( $this->site_data['alias_domains'] ); - $domains_to_add = $split_domains( $add_domains ); - $domains_to_delete = $split_domains( $delete_domains ); + $existing_alias_domains = split_alias_domains( (string) $this->site_data['alias_domains'] ); + $domains_to_add = split_alias_domains( $add_domains ); + $domains_to_delete = split_alias_domains( $delete_domains ); if ( empty( $domains_to_add ) && empty( $domains_to_delete ) ) { EE::error( 'Please provide at least one alias domain to add or delete.' ); diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index d7afe955..0046fec7 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -741,6 +741,23 @@ function check_alias_in_db( $domains ) { } } +/** + * Splits a comma separated list of alias domains, trimming them and dropping blank entries. + * + * @param string|bool $domains Comma separated alias domains, as passed to the alias domain flags. + * + * @return array + */ +function split_alias_domains( $domains ) { + + // A flag passed without a value is `true`, which would otherwise become the alias domain `1`. + if ( ! is_string( $domains ) ) { + return []; + } + + return array_values( array_filter( array_map( 'trim', explode( ',', $domains ) ), 'strlen' ) ); +} + /** * 'sysctl' parameters for docker-compose file. * diff --git a/src/site-type/HTML.php b/src/site-type/HTML.php index 39869625..ca50edd2 100644 --- a/src/site-type/HTML.php +++ b/src/site-type/HTML.php @@ -12,6 +12,7 @@ use function EE\Site\Utils\get_public_dir; use function EE\Site\Utils\get_webroot; use function EE\Site\Utils\check_alias_in_db; +use function EE\Site\Utils\split_alias_domains; use function EE\Utils\get_flag_value; /** @@ -116,9 +117,9 @@ public function create( $args, $assoc_args ) { \EE::error( sprintf( "Site %1\$s already exists. If you want to re-create it please delete the older one using:\n`ee site delete %1\$s`", $this->site_data['site_url'] ) ); } - $alias_domains = \EE\Utils\get_flag_value( $assoc_args, 'alias-domains', '' ); + $alias_domains = split_alias_domains( \EE\Utils\get_flag_value( $assoc_args, 'alias-domains', '' ) ); - $alias_domain_to_check = explode( ',', $alias_domains ); + $alias_domain_to_check = $alias_domains; $alias_domain_to_check[] = $this->site_data['site_url']; check_alias_in_db( $alias_domain_to_check ); @@ -127,17 +128,7 @@ public function create( $args, $assoc_args ) { $this->skip_status_check = \EE\Utils\get_flag_value( $assoc_args, 'skip-status-check' ); $this->site_data['site_container_fs_path'] = get_public_dir( $assoc_args ); - $this->site_data['alias_domains'] = $this->site_data['site_url']; - $this->site_data['alias_domains'] .= ','; - if ( ! empty( $alias_domains ) ) { - $comma_seprated_domains = explode( ',', $alias_domains ); - foreach ( $comma_seprated_domains as $domain ) { - $trimmed_domain = trim( $domain ); - $this->site_data['alias_domains'] .= $trimmed_domain . ','; - } - } - - $this->site_data['alias_domains'] = substr( $this->site_data['alias_domains'], 0, - 1 ); + $this->site_data['alias_domains'] = implode( ',', array_merge( [ $this->site_data['site_url'] ], $alias_domains ) ); $this->site_data['site_ssl'] = get_value_if_flag_isset( $assoc_args, 'ssl', 'le' ); if ( 'custom' === $this->site_data['site_ssl'] ) { From b7c98f7504b3fa4c23e1302827b9560e8b46c7a2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:50:54 +0000 Subject: [PATCH 6/7] feat(site): reject invalid alias domain names Alias domains were stored as given, so names like `../evil`, `a..b`, `a.com.` or `default` reached VIRTUAL_HOST and the per-domain proxy files, where `default` and `default_admin_tools` map onto the global auth and ACL files. `validate_alias_domains()` now allows only a hostname or `*.hostname` whose dot-separated labels use letters, digits, `-` and `_` (not starting or ending with `-`), and rejects `default` and `default_admin_tools` in any case. `ee site update --add-alias-domains` and `ee site create --type=html --alias-domains` exit with an error listing the invalid names before changing anything. Alias domains being deleted aren't checked, so existing invalid ones can still be removed. --- src/helper/class-ee-site.php | 3 +++ src/helper/site-utils.php | 23 +++++++++++++++++++++++ src/site-type/HTML.php | 2 ++ 3 files changed, 28 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 1b814fd9..69e40433 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -27,6 +27,7 @@ use function EE\Site\Utils\reload_global_nginx_proxy; use function EE\Site\Utils\get_parent_of_alias; use function EE\Site\Utils\split_alias_domains; +use function EE\Site\Utils\validate_alias_domains; /** * Base class for Site command @@ -511,6 +512,8 @@ protected function update_alias_domains( $args, $assoc_args ) { EE::error( 'Please provide at least one alias domain to add or delete.' ); } + validate_alias_domains( $domains_to_add ); + $already_added_domains = array_intersect( $existing_alias_domains, $domains_to_add ); $domains_to_add = array_values( array_diff( $domains_to_add, $existing_alias_domains ) ); diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 0046fec7..feb739bd 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -758,6 +758,29 @@ function split_alias_domains( $domains ) { return array_values( array_filter( array_map( 'trim', explode( ',', $domains ) ), 'strlen' ) ); } +/** + * Exits with an error listing the alias domains that are not a plain hostname or `*.hostname`. + * + * Alias domains are also used as proxy file names (e.g. auth-command's htpasswd and ACL files), so the global `default` names are rejected too. + * + * @param array $domains Alias domains. + */ +function validate_alias_domains( $domains ) { + + $label = '[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?'; + $invalid = array_filter( + $domains, + function ( $domain ) use ( $label ) { + return 1 !== preg_match( '/^(?:\*\.)?' . $label . '(?:\.' . $label . ')*$/D', $domain ) + || in_array( strtolower( $domain ), [ 'default', 'default_admin_tools' ], true ); + } + ); + + if ( ! empty( $invalid ) ) { + \EE::error( sprintf( 'Invalid alias domain(s): %s. An alias domain must be a hostname or `*.hostname` whose labels use letters, digits, `-` and `_` (not starting or ending with `-`), and can not be `default` or `default_admin_tools`.', implode( ', ', $invalid ) ) ); + } +} + /** * 'sysctl' parameters for docker-compose file. * diff --git a/src/site-type/HTML.php b/src/site-type/HTML.php index ca50edd2..c773ca9e 100644 --- a/src/site-type/HTML.php +++ b/src/site-type/HTML.php @@ -13,6 +13,7 @@ use function EE\Site\Utils\get_webroot; use function EE\Site\Utils\check_alias_in_db; use function EE\Site\Utils\split_alias_domains; +use function EE\Site\Utils\validate_alias_domains; use function EE\Utils\get_flag_value; /** @@ -118,6 +119,7 @@ public function create( $args, $assoc_args ) { } $alias_domains = split_alias_domains( \EE\Utils\get_flag_value( $assoc_args, 'alias-domains', '' ) ); + validate_alias_domains( $alias_domains ); $alias_domain_to_check = $alias_domains; $alias_domain_to_check[] = $this->site_data['site_url']; From 38569dfb6724bf0ce4146fc325ea5e5a448c7c5d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:03:26 +0000 Subject: [PATCH 7/7] fix(site): reject alias domain labels that start with an underscore An alias like `_wildcard.example.com` passed validation, but it shares its proxy file names with the `*.example.com` files of site example.com, so auth-command could delete or overwrite them. Labels can no longer start with `_` (hostnames like `my_blog.example.com` still work). The rule is now also exposed per name as `is_valid_alias_domain()`, and the global proxy file names as `is_reserved_proxy_file_name()`, so auth-command can use the same rule. --- src/helper/site-utils.php | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index feb739bd..886abf4b 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -759,25 +759,50 @@ function split_alias_domains( $domains ) { } /** - * Exits with an error listing the alias domains that are not a plain hostname or `*.hostname`. + * Checks whether a name is one of the global proxy file names (e.g. auth-command's htpasswd and ACL files), in any case. + * + * @param string $name File name. + * + * @return bool + */ +function is_reserved_proxy_file_name( $name ) { + + return in_array( strtolower( (string) $name ), [ 'default', 'default_admin_tools' ], true ); +} + +/** + * Checks whether an alias domain is a plain hostname or `*.hostname` that is safe to use as a proxy file name. * - * Alias domains are also used as proxy file names (e.g. auth-command's htpasswd and ACL files), so the global `default` names are rejected too. + * @param string $domain Alias domain. + * + * @return bool + */ +function is_valid_alias_domain( $domain ) { + + // No leading `_`, so an alias can't take over the `_wildcard.` files of another site. + $label = '[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?'; + + return is_string( $domain ) + && 1 === preg_match( '/^(?:\*\.)?' . $label . '(?:\.' . $label . ')*$/D', $domain ) + && ! is_reserved_proxy_file_name( $domain ); +} + +/** + * Exits with an error listing the alias domains that are not a plain hostname or `*.hostname`. * * @param array $domains Alias domains. */ function validate_alias_domains( $domains ) { - $label = '[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?'; $invalid = array_filter( $domains, - function ( $domain ) use ( $label ) { - return 1 !== preg_match( '/^(?:\*\.)?' . $label . '(?:\.' . $label . ')*$/D', $domain ) - || in_array( strtolower( $domain ), [ 'default', 'default_admin_tools' ], true ); + function ( $domain ) { + return ! is_valid_alias_domain( $domain ); } ); if ( ! empty( $invalid ) ) { - \EE::error( sprintf( 'Invalid alias domain(s): %s. An alias domain must be a hostname or `*.hostname` whose labels use letters, digits, `-` and `_` (not starting or ending with `-`), and can not be `default` or `default_admin_tools`.', implode( ', ', $invalid ) ) ); + \EE::error( sprintf( 'Invalid alias domain(s): %s. An alias domain must be a hostname or `*.hostname` whose labels use letters, digits, `-` and `_`, do not start with `-` or `_` (a leading `_` is reserved for proxy files like `_wildcard.`) and do not end with `-`. It can not be `default` or `default_admin_tools`.', implode( ', ', $invalid ) ) ); } }