Skip to content

Powered by Grav

Login/admin IP allow-list — `user_admin_access`

Restricting /user + /admin to an IP allow-list (user_admin_access)

The whole-site ip_access generator locks a whole site. Often you instead want the public site to stay open to everyone while only the login and admin surface — Drupal's /user and /admin URIs — is reachable from a trusted set of addresses. user_admin_access does exactly that: a per-site Nginx allow-list scoped to those paths, returning 403 to anyone else who requests them and leaving the rest of the site public.

This is the operator-side reference for the feature hosted customers drive from their own account as the login/admin IP lock.

Like whole-site ip_access, it is a pure Nginx allow/deny layer with no CSF involvement, so it accepts IPv4 and IPv6, single addresses and CIDR subnets. The two features are independent and compose — a site may use either, both, or neither; the only difference is scope (whole-site vs the /user + /admin surface).

The generator and its fragments

It is a single global generator deployed at /var/xdrago/user_admin_access.sh, run over every real Octopus instance (the hostmaster front-end has its own vhost and is out of scope); there is no master/sqladmin context. For each listed site it writes two Nginx includes into the instance's config/includes/:

  • user_admin_access_map/<site>.conf (http scope) — a geo classifying the client $remote_addr as allowed, a map flagging a /user|/admin request on the clean $uri, and a final map $ua_deny_<hash> that is 1 only when an admin-area request arrives from a non-allowed address;
  • user_admin_access/<site>.conf (server scope) — a single if ($ua_deny_<hash>) { return 403; }.

BOA enforces Drupal clean URLs, so /user and /admin always arrive as the real request path in $uri (which Nginx percent-decodes and normalises); the match is keyed there.

The legacy ?q=admin query form is not a clean path — not served by default on BOA — and is deliberately not matched, because Nginx's $arg_q cannot be reliably gated (it is neither percent-decoded nor de-duplicated the way Drupal reads $_GET['q']); this matches BOA's existing location ^~ /admin guard, which likewise keys on the clean path.

The per-site vhost pulls the first once at the file head and the second inside each serving server block (next to ip_access), both via a wildcard include anchored on the fragment suffix (<uri>.conf*, not a bare <uri>* — otherwise a longer site whose name extends this one, e.g. example.com vs example.com.au, would have its fragment pulled in here too).

A site with no fragment is a no-op — the feature is strict opt-in. <hash> is a short digest of the site name, so each site's variables are unique in the shared http{}.

Control file format

One site per line — the site name followed by space-separated allowed addresses, each an IPv4 or IPv6 address with an optional CIDR prefix:

TXT
# /data/disk/o1/static/control/ip/user_admin.txt
intranet.example.com   203.0.113.10 203.0.113.0/24 2001:db8::/32
staging.example.com    198.51.100.42 2001:db8:1::1
  • # comments and blank lines are ignored.
  • Each address is validated against a strict subset of what Nginx geo accepts; a malformed address or an invalid site name is skipped with a logged warning while the rest of the line still applies — one bad token never breaks the box-wide Nginx configtest.

Anti-lockout

Every generated geo always allows, in addition to the listed addresses, so a typo can never lock the operator out of the admin surface:

  • 127.0.0.1 and ::1 — loopback;
  • the server's own IPv4, read from /root/.found_correct_ipv4.cnf (BOA tracks no server IPv6);
  • every established inbound SSH client IP (IPv4 or IPv6), harvested from netstat -tn — the same source ip_access uses.

Generator behaviour

  • Change-gate — a context regenerates only when its control file's mtime advanced, the host's SSH-client set changed, or the emitted-directive version bumped. No change → no write, no reload.
  • Prune-on-removal — deleting a site's line removes both its fragments on the next run, lifting the restriction (the admin surface becomes open again).
  • Safety — per context: back up the current fragments, regenerate, run service nginx configtest, then reload; on failure, restore the last-good backup and reload. It holds the shared /run/boa_nginx_config.lock so it never overlaps the other Nginx-config writers (ip_access, ai_policy, nginx_deny, cloudflare_realip).
  • Schedule*/2 cron, so changes take effect within about two minutes.

Defence in depth, not the sole control

This gate narrows who can reach the /user and /admin URL paths at the edge; Drupal's own login and permission checks still apply on top of it.

The match is on the decoded, normalised $uri, so it is encoding- and multi-slash-safe. Under the hood the map keys on $uri with ~*^/+(?:user|admin)(?:/|$), and the ^/+ anchor deliberately accepts one or more leading slashes — so a //admin request is still caught even on the rare vhost that has turned Nginx's default merge_slashes off, and the gate never relies on Nginx collapsing the slashes for it.

The legacy ?q=admin query form is not gated here (it is not a clean path and not served by default on BOA, and $arg_q cannot be reliably matched at the Nginx layer) — Drupal's authentication remains the control for that vector, exactly as with BOA's existing /admin guard.

Verify

BASH
# inspect a site's generated gate
cat /data/disk/o1/config/includes/user_admin_access_map/intranet.example.com.conf
cat /data/disk/o1/config/includes/user_admin_access/intranet.example.com.conf

# from a non-allowed IP: /admin + /user -> 403, everything else -> 200
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/admin
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/

service nginx configtest

The script is aegir/tools/system/user_admin_access.sh, deployed as /var/xdrago/user_admin_access.sh; the per-site includes are emitted by the vhost generator (provision-private/http/Provision/Config/Nginx/vhost.tpl.php and the SSL template).

© 2026 BOA Documentation. All rights reserved.