Skip to content

feat(auth): support http auth and ip whitelist on wildcard subdomains and alias domains - #57

Merged
mrrobot47 merged 30 commits into
EasyEngine:developfrom
mrrobot47:feat/wildcard-auth
Sep 25, 2026
Merged

mrrobot47 merged 30 commits into
EasyEngine:developfrom
mrrobot47:feat/wildcard-auth

Conversation

@mrrobot47

@mrrobot47 mrrobot47 commented Jan 8, 2026 •

Copy link
Copy Markdown
Member

Summary

HTTP auth and IP whitelists only covered a site's own domain: htpasswd/<site> and <site>_acl were the only files written, so subdomains of a subdomain multisite and alias domains were served with the global auth or none at all. auth-command now writes the files for every domain a site is served on, keeps them in sync when alias domains change, removes all of them on site delete, and regenerates them on upgrade.

Changes

  • Write a site's htpasswd and ACL files for all of its domains: the site itself, _wildcard.<site> for a subdomain multisite, each alias domain, and _wildcard.X for a *.X alias. Plain alias domains get no _wildcard file.
  • Decide from the site's own auth and whitelist entries (not the merged global ones) whether it gets files at all, so sites without their own entries fall back to the global default files.
  • Load src/helper/hooks.php from the Composer autoload files. Its site_cleanup hook was never loaded before; it now removes the auth and whitelist rows and the htpasswd and ACL files of every domain the site uses on site delete, and reloads the proxy only when something was removed.
  • Listen to site-command's alias domain hooks: site_alias_domains_before_update writes the files for added alias domains before the proxy serves them, site_alias_domains_update_failed removes them again if the update is aborted, and site_alias_domains_updated removes the files of deleted alias domains.
  • Skip alias domain names that site-command's is_valid_alias_domain() rejects (anything but a plain hostname or *.hostname, labels starting with _ such as _wildcard.X, and the reserved default / default_admin_tools names) before turning them into file names; file removal only deletes regular files directly inside the htpasswd or vhost.d directory and never the global files.
  • Write each htpasswd file once to a temp file and rename it into place, then copy it to the site's other domains the same way, instead of one docker exec htpasswd per user and domain. A failed write leaves the existing files unchanged, including the global default file.
  • Shell-escape every htpasswd argument, and keep passwords and usernames out of ee.log.
  • Container migration 20260924120000_auth-command_regenerate_site_auth_files regenerates every site's htpasswd and ACL files on upgrade (removing the files of sites without their own entries) and reloads the proxy once. A failing site is logged and skipped. It is skipped on fresh installs and when there are no sites, and its down() is empty.

Release / merge order

These PRs ship together in one core release:

Order:

  1. Merge the dockerfiles PR, tag a new dockerfiles release and confirm the new easyengine/nginx-proxy image is on Docker Hub.
  2. Merge the site-command, auth-command, site-type-wp and site-type-php PRs close together (nightly and PR CI build dev-develop of every package) and tag each package.
  3. In one core release, bump the four package pins and easyengine/nginx-proxy in img-versions.json together.

Why they are coupled:

  • auth-command's container migration regenerates every site's auth files on upgrade and needs the new nginx-proxy image in the same release: the old template applies a _wildcard.X file to sibling sites too (e.g. shop.example.com picks up _wildcard.example.com).
  • auth-command syncs alias domain auth files from the site_alias_domains_before_update, site_alias_domains_updated and site_alias_domains_update_failed hooks, which only the new site-command fires.
  • site-command no longer removes a site's auth files and rows on site delete; auth-command's site_cleanup hook (autoloaded since feat(auth): support http auth and ip whitelist on wildcard subdomains and alias domains #57) does it.
  • site-type-wp and site-type-php call site-command's new split_alias_domains() and validate_alias_domains(), and auth-command its new is_valid_alias_domain() and is_reserved_proxy_file_name(); with an older site-command, they fail with a fatal "undefined function" error.
  • The dockerfiles change is breaking for hand-made feat(nginx-proxy): Add wildcard HTTP auth support for WordPress multisite dockerfiles#298-style _wildcard.X files that were also relied on to protect X itself: X now needs its own htpasswd/X (and vhost.d/X_acl) file.

Testing

  • Tested on a live EasyEngine 4.12 install with html, php, wp, wp subdir and wp subdom sites, with plain and *. aliases, multiple users, global auth, IP whitelists, alias add/remove, site delete, self-signed SSL and Let's Encrypt.
  • Upgrade migration tested from 4.11 state.
  • PHP 7.4–8.5 lint.

- Modified generate_site_auth_files() to create _wildcard.site_url file for
  subdomain multisites (app_sub_type === 'subdom')
- Handle alias_domains: create htpasswd files for each alias domain
- Convert *.domain format aliases to _wildcard.domain
- For subdomain multisites, also create wildcard versions of alias domains
- Updated all callers to pass site_data parameter
- Fetch site data from DB when regenerating auth for all sites globally

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
Add array_unique() to deduplicate domains array before processing,
preventing duplicate htpasswd file creation when alias domains overlap
with wildcard domains (e.g., *.example.com alias on a subdomain multisite)

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
- Modified generate_site_whitelist() to create whitelist files for
  _wildcard.site_url on subdomain multisites
- Handle alias_domains: create whitelist files for each alias domain
- Convert *.domain format aliases to _wildcard.domain
- For subdomain multisites, also create wildcard versions of alias domains
- Updated all callers to pass site_data parameter
- Fetch site data from DB when regenerating whitelist for all sites globally

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
When alias_domains contains *.site_url (e.g., *.example.com for site
example.com), skip it ONLY if the site is a subdomain multisite since
_wildcard.site_url is already added in that case. For non-multisite
sites with *.site_url alias, create the _wildcard.site_url file.

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
When no auths or whitelists exist for a site, explicitly remove all
related htpasswd/whitelist files (including wildcards and aliases) and
return early. This makes the cleanup behavior more explicit rather than
silently iterating over an empty array.

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
Always remove _wildcard.site_url htpasswd and whitelist files at the
start of regeneration. This ensures orphan wildcard files are cleaned
up when a site changes from subdomain multisite to regular site type.

Signed-off-by: Riddhesh Sanghvi <riddhesh237@gmail.com>
Copilot AI review requested due to automatic review settings January 8, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for wildcard htpasswd and IP whitelist files for WordPress multisite subdomain installations and alias domains. The main purpose is to enable HTTP authentication and IP whitelisting to work correctly on subdomains of multisite installations (e.g., site1.example.com, site2.example.com) by creating _wildcard.example.com configuration files.

Key changes:

  • Modified generate_site_auth_files and generate_site_whitelist methods to accept optional $site_data parameter
  • Added logic to create wildcard (_wildcard.domain) htpasswd/whitelist files for subdomain multisites
  • Added support for processing alias domains, including converting *.domain format to _wildcard.domain filenames
  • Updated all call sites to pass the $site_data parameter

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Auth_Command.php Outdated
Comment thread src/Auth_Command.php Outdated
Auth and whitelist generation duplicated the same domain collection logic. Move it into get_site_auth_domains() and move site-level htpasswd/ACL generation into auth-utils so other callers (hooks) can reuse it. No behaviour change.
A plain alias of a subdomain multisite is never served as *.<alias>, so _wildcard.<alias> protected nothing and could leak onto unrelated hosts.
The unconditional removal of _wildcard.<site> could delete a file generated for another site's *.<site> alias. A site's type can't change from subdomain multisite to a regular site, so the cleanup isn't needed.
Whether to remove a site's htpasswd/ACL files was decided from the merged global and site entries. Since a global whitelist always exists (and global auth may too), site files were never removed and kept overriding the global default files. Decide from the site's own entries instead.
The htpasswd command was built by interpolating the file name, username and password into a shell command. Build it in one helper that shell-escapes every argument and use it for all htpasswd writes.
The site_cleanup hook in src/helper/hooks.php was never loaded because the file wasn't in the autoload files, so site deletion only removed htpasswd/<site> (via site-command) and left _wildcard.<site>, alias htpasswd files and every _acl file behind. These orphans then protected later, unrelated sites with the same names.

Load the hooks file and remove the htpasswd and ACL files of every domain the site uses, whether or not it still has auth entries.
Adding an alias with `ee site update --add-alias-domains` didn't generate its htpasswd/ACL files, so the new alias was served without the site's auth until an `ee auth` command was re-run. Removing an alias left its files behind.

Listen to site-command's site_alias_domains_updated hook: remove the files of removed aliases, regenerate the site's htpasswd and whitelist files when the site has its own entries, and reload the proxy. Requires a site-command release that fires the hook.
Alias names now have to be a plain hostname or `*.hostname` before they are turned into htpasswd/ACL file names; empty names, `default`, `default_admin_tools` and anything path-like are skipped. An empty alias used to make remove_auth_files() pass the htpasswd directory itself to Filesystem::remove(), wiping every auth file, and `default` deleted the global auth and ACL files.

Removal now only deletes a regular file directly inside the htpasswd or vhost.d directory, never touches the global files, and reports whether anything was removed.
…r domains

generate_site_auth_files() ran one `docker exec htpasswd` per user and per domain. It now writes the file of the site itself in the proxy container and copies it on the host to the `_wildcard.<site>` and alias files, keeping the source's mode so the result doesn't depend on the umask.

If writing fails the existing files are left in place with a warning instead of being removed upfront, so a failed exec can no longer leave the site unprotected.
EE::exec() logs every command in debug mode, so each `htpasswd -b` call wrote the plain password and username to ee.log. write_htpasswd_file() now passes them as obfuscated values, and the admin-tools auth is written through it too.
The htpasswd and ACL files of new alias domains were written from `site_alias_domains_updated`, after docker-compose had already brought up the new VIRTUAL_HOST, so a protected site answered 200 without credentials on the new alias for a few seconds.

The new `site_alias_domains_before_update` hook now copies the site's htpasswd file and writes its ACL file for the added aliases before the containers are recreated, but only for the files the site has own entries for. If the update is aborted, `site_alias_domains_update_failed` removes those files again, keeping every file the site still uses. The post-update hook still removes the files of deleted aliases once they are no longer served, and regenerates the files from the saved site only when aliases were added.
…emoved

The `site_cleanup` hook reloaded the global proxy on every site delete, also for sites that never had auth or whitelist entries or files. It now reloads only when it deleted a row or a file.
…bal files

Site::find() uses the `site_url` primary key and returns false on a miss, which is mapped to null as before.
Installs upgraded from a version that only wrote `htpasswd/<site>` and `<site>_acl` would keep subdomains (`_wildcard.<site>`) and alias domains unprotected until an `ee auth` command touched the site, and kept per-site files of sites without own entries.

A container migration now regenerates the htpasswd and ACL files of every site, which also removes the files of sites without own entries, and then reloads the proxy once. A failing site is logged and skipped instead of failing the migration. It is skipped on fresh installs and when there are no sites, and has nothing to revert since the files are also valid for the previous version.
`default` and `default_admin_tools` were listed separately in the alias domain check, the file removal guard and add_site_auth_files(). They now come from RESERVED_AUTH_FILE_NAMES through is_reserved_auth_file_name(), which also matches them in any case, as the alias domain check in site-command does.
is_valid_alias_domain() only allowed letters, digits and `-` in labels, so an alias domain like `my_blog.example.com` got no htpasswd or ACL file and was served without the site's auth. Labels may now also contain `_`, matching the alias domain names site-command accepts.
htpasswd rewrote the live file entry by entry (`-c` for the first, then one call per user), so the proxy could read a file with only some of the users, and a failure midway left it that way. The entries are now written to `.<name>.tmp` in the htpasswd directory, a name no host can match, and the file is renamed over the real one once all entries are in; the site's other domains are then copied from it as before. On failure the temp file is removed, the existing file is left unchanged, and write_htpasswd_file() warns, so the callers no longer do.
htpasswd prints `Adding password for user <name>` on STDERR, and EE::exec() logs the output of every command, so each write still put the usernames in ee.log even though the command itself was masked. The htpasswd output is now discarded; failures are still detected through the exit code, which write_htpasswd_file() checks.
… site's generators

add_site_auth_files() reimplemented the htpasswd and ACL generation for the alias domains about to be added. generate_site_auth_files(), generate_site_whitelist() and get_site_auth_domains() now take the alias domains not saved on the site yet, and add_site_auth_files() just calls the two generators with them. It no longer returns whether anything was written, which no caller used. Stale files for the new names are now also removed when the site has no own entries, so they fall back to the global files.

The post-update hook no longer regenerates everything (running htpasswd again) whenever alias domains were added: it only rewrites the files if one the site's entries call for is missing, and reloads the proxy only when it changed something.
…when it can't be rewritten

generate_site_auth_files() gave up when htpasswd could not run, e.g. when nginx-proxy is stopped while the upgrade migration runs. The migration was then recorded as done, but the subdomain and alias domain files it exists to create were never written, leaving those hosts on the global auth. If the site's own `htpasswd/<site>` exists, it is now copied host-side to the site's other domains, as the alias domain hook did before, so they are at least as protected as the site itself.
The down() comment claimed the regenerated files are also valid for the previous version. With the previous nginx-proxy template a `_wildcard.X` file also protects sibling hosts that share the last labels (e.g. shop.example.com picks up _wildcard.example.com), so the files written here are only correct with the nginx-proxy image shipped in the same release.
Alias domains are now checked with site-command's `is_valid_alias_domain()` and the global file names with its `is_reserved_proxy_file_name()`, instead of a separate copy of the rule that disagreed with it (it accepted `-a.com` and `a-.com`). This also skips aliases whose labels start with `_`, so an alias like `_wildcard.example.com` can no longer delete or overwrite the `*.example.com` files of site example.com.
…tten

Regenerating global auth removed `htpasswd/default` and `default_admin_tools` before writing the new file, so a failed write left every host that falls back to global auth unprotected. `default` is now only removed when there are no global auths and otherwise replaced atomically, and `default_admin_tools` is removed only once `default` is written. The global rows are also no longer regenerated a second time as if they were sites.
Each copy is written under a name no host matches and then renamed, so the proxy never reads a truncated file. A failed copy is cleaned up and leaves the existing file unchanged.
mrrobot47 added a commit to EasyEngine/site-command that referenced this pull request Sep 25, 2026
site-command no longer removes `htpasswd/<site>` 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.<site>`, 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.
@mrrobot47
mrrobot47 merged commit 7f55d67 into EasyEngine:develop Sep 25, 2026
mrrobot47 added a commit to mrrobot47/auth-command that referenced this pull request Sep 25, 2026
EasyEngine#57 is on develop now: AUTH-11 (site delete removes the _acl file) and AUTH-12 (shell metacharacters in passwords) pass, so they become regular regression scenarios, and a site without own users or IPs no longer keeps an htpasswd/_acl file (5f12c49), so the two scenarios that asserted the old files check that the files are gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants