POST /api/deployments/{id}/access) no longer sits behind Caddy’s
native HTTP Basic Auth popup. Instead, a visitor sees a styled “This
deployment is protected” page offering two ways in: the deployment’s own
password, or signing in with a Mangrove account. Modeled on the same idea
as Vercel/Cloudflare Access-style deployment protection pages.
Why not just Caddy’s http_basic auth
The old mechanism worked but couldn’t offer a “continue with your account”
option or any custom styling — it’s a browser-native credentials prompt,
full stop. Getting a real page in front of visitors means Caddy can no
longer gate the route by itself; something has to decide, per request,
whether to render a page or let traffic through.
The flow
internal/proxy/caddy.go’s gateHandler replaces the normal
reverse_proxy/file_server handler on a password-protected route (port-based
and custom-domain routes — see below) with an unconditional reverse
proxy to Mangrove’s own 127.0.0.1:<APIPort>, tagged with
X-Mangrove-Gate-Deployment: <id> (Caddy’s headers.set always overwrites
any client-supplied value of the same name, and the API port is
loopback-only, so this header can be trusted). internal/api/gate.go’s
gateIntercept middleware, mounted ahead of all other routing, catches
every request carrying that header and handles it entirely on its own —
it never reaches /api, /healthz, or the dashboard SPA:
- No valid
mangrove_gatecookie, but the request path matches one of the deployment’s public paths (below) -> proxy straight through. - No valid
mangrove_gatecookie yet -> render the gate page. POST /__mangrove_gate__/password-> check the deployment’s stored bcrypt hash (Store.GetDeploymentPasswordHash) directly; on success, set the cookie and redirect back.GET /__mangrove_gate__/callback?token=...-> the far side of the account handoff below.- A valid cookie ->
Orchestrator.GateUpstreamsresolves where the real app actually is (a running container’s address, or a static build’s output directory) and Mangrove reverse-proxies the request through itself (gateProxyThrough).
Unprotected pages (public paths)
An owner can leave chosen pages open on an otherwise-protected deployment (a landing page, a pricing page, a health endpoint). The Access control card’s “Unprotected pages” box (orpublic_paths in
POST /api/deployments/{id}/access) takes one pattern per line, stored
newline-separated in deployments.public_paths (migration 0015):
/pricing— exactly that path (so/pricing/is not covered)./docs/*— a prefix: everything under/docs/./assets*is a prefix without the slash boundary.
internal/gatepaths (Normalize validates on save, Match
runs per request in handleGatedRequest, after the cookie check so a signed-in
visitor is never affected). A request path that isn’t already canonical
(.., //, /./, backslashes) never matches, so /docs/../admin can’t
ride a /docs/* rule past the gate. Query strings are ignored for matching.
Only the path is checked — a public page’s own CSS/JS/API calls need their
own entries (/assets/*), or they’ll hit the gate. The list is cleared when
password protection is turned off. Re-saving while already protected with a
blank password keeps the existing password, so the list can be edited alone.
The account handoff
The protected deployment and the dashboard are different origins, so there’s no session cookie to just check — this is a small OAuth-shaped redirect dance, all three legs backed by signed, tamper-evident tokens (internal/gateauth, sealed under the same master key as secrets at rest;
no new database table):
- The gate page’s “Continue with Mangrove account” button links to
<PublicURL>/gate-auth?token=<handoff token>— minted on the deployment’s own domain, TTL 5 minutes. The return URL travels inside the token;/gate-authnever trusts a raw query parameter for it, since that endpoint is a public, unauthenticated GET anyone could link to. /gate-auth, on the dashboard’s own domain, checks the visitor’s existingmangrove_sessioncookie (or shows a small standalone login form if there isn’t one — any role, owner or member, can complete this; see multi-user.md, there’s no finer-grained per-project ACL to check). On success it mints a callback token (TTL 2 minutes) and redirects back to the deployment’s own domain.GET /__mangrove_gate__/callbackredeems that token, sets themangrove_gatecookie, and redirects to the page the visitor originally asked for.
MANGROVE_PUBLIC_URL isn’t set, there’s no safe absolute origin to send
a visitor back to, so the account button is simply omitted — the password
field still works.
Custom domains
internal/orchestrator/domains.go’s pushCustomDomainRoute passes the
same RouteOptions (built from the deployment’s own PasswordProtected
flag) that the port-based route gets, and SetAccessControl re-pushes a
deployment’s verified custom domain route on every access-control change —
previously password protection only actually took effect on the port-based
route, never on a custom domain.
Known limitations
- No caching on the resolve-upstream path.
GateUpstreamsdoes a live lookup (container address resolution, or a store read) on every request that passes the gate. Fine for what this is designed for — a low-traffic staging gate, matching the existing “flip a staging build to password-protected without touching app code” framing — not meant for a high-throughput production path. - Two protected deployments on the same bare hostname, different ports,
no custom domain, will fight over the same cookie. Cookie scoping
ignores port, so a
mangrove_gatecookie set forhost:31000is also sent tohost:31001— since the cookie’s payload is itself deployment- bound (via AAD), the wrong deployment just rejects it and re-shows its gate page, but only the most recently authenticated one stays “logged in” per hostname. A verified custom domain doesn’t have this problem.