Skip to content

Powered by Grav

Whole-site IP allow-list — `ip_access`

Restricting a whole site to an IP allow-list (ip_access)

The /admin* URL protection is path-scoped. For a stronger control — locking an entire site (an intranet, a staging site, an admin-only area) to a set of IP addresses — BOA ships ip_access, a single global generator that emits per-site Nginx allow/deny fragments. Everything not on the list gets a 403 (Nginx deny all;).

This is the operator-side reference for the feature hosted customers drive from their own account as the site IP lock. For the path-scoped variant limited to /user + /admin, see user_admin_access.

The generator and its contexts

It is a single global generator deployed at /var/xdrago/ip_access.sh, replacing the older per-Octopus nginx_ip_access_<oct>.sh copies. It runs one shared routine over two kinds of context:

  • the master (sqladmin proxy): /var/aegir/control/ip/access.txt/var/aegir/config/includes/ip_access/. On each run the generator auto-seeds this control file with the record sqladmin.com 192.168.1.1 when it is absent, producing a sqladmin.conf fragment. That fragment is what actually guards the SQL-admin proxies — it is pulled in by the Adminer/phpMyAdmin-style vhosts (nginx_sql_adminer.conf, nginx_sql_buddy.conf, nginx_sql_cgp.conf, nginx_sql_chive.conf) and by ssl_proxy.conf via include .../ip_access/sqladmin*;. The seed IP 192.168.1.1 is a private placeholder, so on a real box only the anti-lockout IPs (loopback, the server's own IP, established SSH clients) can reach the SQL admin tools until an operator adds real public IPs to that control file;
  • every Octopus instance: /data/disk/<oct>/static/control/ip/access.txt/data/disk/<oct>/config/includes/ip_access/. Only real instances are processed (identified by the BOA-canonical tools/drush marker, so pseudo-dirs like arch, all, legacy, global, static, custom are skipped).

The per-site vhost pulls the fragment via include $server->include_path/ip_access/<uri>.conf*, so the restriction applies to that site only (the .conf* suffix anchors the glob to this site's own fragment; a bare <uri>* would also match a longer site whose name extends it, e.g. example.com vs example.com.au).

A site with no record has no fragment and is unrestricted. (The sqladmin* master include above is deliberately a broader prefix glob — it matches the seeded sqladmin.com.conf.)

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/access.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 the Nginx access module 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 fragment always allows, in addition to the listed IPs, so a typo can never strand the site or the operator:

  • 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 (peers on an ESTABLISHED :22 connection — the peer address is taken by stripping the trailing :port, and each is validated before it reaches an allow line). BOA uses netstat here, not who --ips, because who --ips is unavailable on Excalibur and newer.

An admin working over SSH is therefore added to every site's allow-list automatically. The SSH-client set is part of the change-gate, so a newly logged-in admin triggers a regenerate on the next pass.

Generator behaviour

  • Change-gate — a context regenerates only when its control file's mtime advanced or the host's SSH-client set changed. No change → no write, no reload.
  • Prune-on-removal — deleting a site's line removes its fragment on the next run, lifting the restriction (the site is open again).
  • Per-context safety — back up current fragments, regenerate atomically, run service nginx configtest, then reload; on a failed configtest or reload, restore the last-good backup and reload (or drop the just-written fragments if there is no last-good yet).
  • Serialisation — the whole run holds the shared /run/boa_nginx_config.lock (flock -w 30, then skip and retry next tick) so it never overlaps the other Nginx-config writers (ai_policy, nginx_deny, cloudflare_realip).
  • Schedule*/2 cron, so changes take effect within about two minutes.

IPv4, IPv6 and CIDR

ip_access is a pure Nginx allow/deny layer — it keys on the recovered client IP at the web tier and does not touch CSF — so it accepts both address families and subnets: each entry may be an IPv4 or IPv6 address, singly or as a CIDR range (203.0.113.0/24, 2001:db8::/32, …).

The validator is a strict subset of what the Nginx access module accepts, so a validated entry can never break the box-wide configtest.

This is independent of CSF: adding an IPv6 rule here restricts the site at Nginx but does not add a host-firewall rule (CSF remains a separate layer, and BOA tracks only the server's own IPv4 for anti-lockout).

The restriction is whole-site; for a path-scoped variant limited to /user + /admin, see user_admin_access.

Behind Cloudflare (realip)

The allow/deny rules key on $remote_addr. With Cloudflare realip active, $remote_addr is the real visitor IP, so enter the visitor's real public IP (what e.g. https://ifconfig.co shows) — which is what an operator naturally does.

In the brief pre-cron window before realip activates, a CF-proxied site would see the CF edge instead; install-time realip activation closes that window on a normal box.

Verify

BASH
# inspect a site's generated allow/deny fragment
cat /data/disk/o1/config/includes/ip_access/intranet.example.com.conf

# from a non-allowed IP -> 403; from a listed IP (or over the
# server / SSH) -> 200
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/

service nginx configtest

The script is aegir/tools/system/ip_access.sh, deployed as /var/xdrago/ip_access.sh.

© 2026 BOA Documentation. All rights reserved.