Best way to keep the admin web UI internal only (v0.16, community edition)

Your question

Hello,

I run Stalwart v0.16.x (community edition, Docker) for a client. Right now the server is reachable from the internet on port 443, and anyone who opens https:///admin sees the admin login page. I want to change that.

What I am trying to achieve:

  • The admin web UI should only be reachable from the local network or over VPN, for example via the LAN IP or a local hostname
  • Everything else must keep working as it does today for public users: webmail, mail clients (IMAP/SMTP/JMAP), autoconfig, autodiscover, OAuth login, health checks

I have read the access control documentation and it mentions an allowedEndpoints expression where you can filter by listener, IP, or path. I also saw an example with a private listener bound to localhost and a public listener for everything else.

My questions:

  1. What is the recommended way to do this in v0.16? A second listener that is not exposed publicly, an IP based rule, or something else entirely?

  2. Is it safe to have the admin UI on a different listener than the API? The WebUI talks to /api, and I do not want to break login or the dashboard.

  3. If I access the admin via a local IP or internal hostname, do I need to set STALWART_PUBLIC_URL or anything similar so that login redirects and discovery documents still point at the right place?

  4. Any pitfalls or known issues with this approach? I would rather use the built-in mechanism than put nginx in front just to block one path.

Setup for reference: Docker on Ubuntu, RocksDB, ports 25/465/587/993/443 published, public hostname serves the web UI today, LAN access is available at 192.168.22.48.

Thanks for your help.

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Now let’s look at the HTTP access-control doc for allowedEndpoints details.

Now let me check the HTTP access control doc directly for allowedEndpoints examples with listener/path scoping.

The recommended approach is exactly the listener-based pattern you found in the access control docs, and it is the pattern Stalwart documents for this specific case.

1. Recommended setup: Create two HTTP NetworkListener objects: e.g. public-http bound to 0.0.0.0:443 (or your public interface) and private-http bound to your LAN IP 192.168.22.48 (or a VPN-only interface) on its own port. Then set allowedEndpoints on the Http singleton so /admin and /account are only allowed when listener == 'private-http', while /jmap, /robots.txt, /.well-known/*, autoconfig/autodiscover, and OAuth endpoints stay allowed on public-http. This is the documented “Restriction by listener” pattern:

{
  "allowedEndpoints": {
    "match": {
      "0": {"if": "listener == 'private-http' || contains(['jmap', 'robots.txt', '.well-known'], split(url_path, '/')[1])", "then": "200"}
    },
    "else": "404"
  }
}

Adapt the path list to also include whatever autoconfig/autodiscover/OAuth paths you serve publicly today, and restrict /admin and /account implicitly (they’ll fall into the 404 branch on the public listener). See HTTP Access Control.

2. Yes, it’s safe to put the admin UI on a different listener than the rest of the API, but be careful: the WebUI’s /admin and /account frontends call /api for the actual data (JMAP calls, /api/discover, etc.). If you restrict by listener as above, make sure /api is reachable on the same listener you’re using for /admin (i.e., don’t try to split /admin and /api across listeners) so login and dashboard calls aren’t blocked. Path-only restriction of /admin while leaving /api open on the public listener would defeat the purpose, since some admin-only API calls may not be prefixed distinctly from /api used by mail clients — the listener-based split avoids this ambiguity entirely.

3. You generally do not need to change STALWART_PUBLIC_URL if you keep using the same public hostname to reach the admin console over the internal network (e.g. via VPN, or by pointing an internal DNS entry / /etc/hosts at your LAN IP for that same hostname). Stalwart’s OAuth flow binds to the configured defaultHostname over HTTPS — accessing by raw IP address will break the OAuth callback. So rather than browsing to https://192.168.22.48/admin directly, set up an internal hostname (same as your public one) that resolves to the private listener’s IP, and keep the certificate valid for that hostname. STALWART_PUBLIC_URL only needs to change if the base URL clients use for discovery documents differs from https://<defaultHostname> (e.g., a proxy on a non-standard port), which isn’t the case here.

4. Known pitfalls:

  • Loading the WebUI by raw IP or over plain HTTP will load the login page but fail at the OAuth callback — use the hostname over HTTPS, or temporarily use http://<host>:8080/admin which is exempt from the hostname check.
  • Don’t split /admin and /api across different listeners with different access rules, since the WebUI depends on both being reachable together.
  • Responding with 404 (rather than 403) for denied paths, as in the examples, avoids advertising that the admin endpoint exists at all to public callers.

Reference: HTTP Access Control, WebUI overview, FAQ – Can I reach the WebUI by IP address?.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

I’d use the plain http listener that exists by default on 8080 for admin, publish it only on the LAN address (-p 192.168.22.48:8080:8080), and refuse /admin on every other listener:

{"match": {"0": {"if": "starts_with(path, '/admin') && listener != 'http'", "then": "404"}}, "else": "200"}

Write path in the expression. The access control docs use url_path, which doesn’t exist in 0.16, and a rule that fails to compile falls back to allowing everything, so a pasted example quietly does nothing. That one’s on us and I’ll fix the page.

Gate only /admin. The UI calls /api and /jmap on whatever origin it was loaded from, so the 8080 listener answers its own API calls, while login, webmail and clients on the public side still need /api, /jmap, /auth, /login and /.well-known. Leave STALWART_PUBLIC_URL pointing at the public name. Over plain HTTP the admin sign-in uses relative endpoints and stays on the LAN address, the same as the first-run setup on 8080. I’d avoid giving the LAN its own HTTPS hostname: over TLS the sign-in endpoints point at the public URL, so login would bounce there and the token exchange would run cross-origin. I haven’t tried that variant, but I don’t expect it to work.

The price is that admin credentials cross the LAN or VPN unencrypted. If you’d rather keep everything on 443, swap the listener check for !is_ip_in_cidr(remote_ip, '192.168.22.0/24') (plus your VPN range) and have the public hostname resolve to 192.168.22.48 internally. Check the logs first to confirm Stalwart sees real LAN addresses and not the Docker bridge gateway, and keep “Use Forwarded IP” off, since with nothing in front anyone can put a LAN address in X-Forwarded-For.

Hiding the page doesn’t stop anyone holding the admin password from using the API over /jmap. For that, set allowedIps on the admin account’s password credential (Account | Stalwart), ideally on a dedicated admin account with no mailbox.