Skip to content

Captive portal

The captive portal is the in-product landing page rendered to a visitor whenever the engine emits a verdict redirect — block, warn, or aup. It also hosts a self-service install page for the SSL-bump CA so unmanaged devices can join the proxy without an MDM / GPO trust-store push.

The portal is not a guest-Wi-Fi email-capture form. Visitors land here because the security gateway intercepted their request. There is deliberately no marketing-consent checkbox — the only data collected is what the gateway needs to authorise, log and audit the request.

The portal is a Python application. It runs as a separate enforcegate-captive-portal service in the bundled docker-compose.yml, fronted by a the TLS terminator tls-terminator service that publishes :80 (redirect to HTTPS) and :443 (TLS) on the host.

Deployment shape

   browser ───► tls-terminator  ───► captive-portal  ───► engine (Control API)
                  :80 redirect → :443        :8000 (compose          :11225 (compose
                  :443 TLS (bump-CA            network only)           network only)
                  signed leaf cert)
  • The browser never speaks directly to the engine; the portal proxies /api/captive/ack server-side to the engine's :11225 Control API listener.
  • The TLS terminator is bare with one upstream — captive-portal:8000. It does not know any of the portal's URL space.
  • The leaf TLS certificate the terminator serves is the engine-issued portal cert, signed by the SSL-bump CA. Operators distribute one CA (the bump CA) to client trust stores and every TLS hop in the deployment chains to it.

Routes

The portal exposes a small, fixed set of routes. The path is the verdict — a given verdict always lands on one URL, with no path-level dispatch from query parameters.

Verdict pages

Rendered in English, French, German and Italian (the four Swiss locales). The visitor's preferred language is selected from Accept-Language with a manual override available in the topbar.

Route Verdict Behaviour
/blocked block Terminal page. Explains the rule that matched and the description text from the policy. No CTA.
/warned warn Two flavours, chosen by the engine. With an ack_token in the inner payload — renders a "Proceed anyway" CTA pointing at /api/captive/ack. Without — renders a terminal alert with no CTA. The engine controls which flavour by including or omitting the token.
/aup aup Same two-flavour pattern as /warned. CTA text is "I acknowledge and continue" instead of "Proceed anyway"; the portal records the acceptance against the configured PRIVACY_POLICY_VERSION so the operator can prove which notice the visitor accepted.

Internal ack endpoint

Route Purpose
/api/captive/ack Server-side proxy to the engine's control-API ack endpoint. Forwards the visitor's ?p=…&v=1&ts=… verbatim and passes the engine's HTTP 302 back to the browser unchanged. Hop-by-hop headers stripped. Returns 404 when the engine URL is empty (single-image deployment without a portal sidecar).

Self-service CA install

When SSL-bump inspection is active and the bump CA has been published into the shared volume, two additional routes appear:

Route Purpose
/ca Self-service install page with per-OS step-by-step instructions for iOS, Android, macOS, Windows, Linux/Debian and Linux/RHEL — plus prominent SHA-256 and SHA-1 fingerprints for out-of-band verification.
/ca.crt Direct PEM download of the bump CA, served at the same origin as the install page.

Both routes 404 when bump mode is not active or the CA path is unreadable, so the feature stays invisible in non-intercepting deployments. Plain-HTTP exposure of /ca and /ca.crt is whitelisted in the bundle's TLS-terminator config (so unmanaged devices can fetch the CA before they have it installed); every other path on :80 redirects to HTTPS.

The page is the unmanaged-device fallback for the CA-distribution problem: managed devices receive the CA via GPO / Intune / Jamf, but BYOD laptops, guest devices and personal phones need a self-service path.

Health and version

Route Purpose
/healthz JSON liveness probe — {"status":"ok","version":"2026.x.y","release_type":"IR"}. Unauthenticated. Compose healthchecks may target this route.
X-Portal-Version Response header on every page. Carries the portal version (with release-type suffix when set).
<meta name="portal-version"> In every <head>. Lets support engineers read the version off any screenshot.

Redirect security

Every URL the engine sends the browser to a verdict page with carries exactly three URL parameters:

  • p — an encrypted payload.
  • v — protocol version (currently "1").
  • ts — Unix epoch seconds, for replay protection.

The encryption key is shared between the engine and the portal via [captive_portal].secret. Operator-visible properties:

  • Visitors cannot read verdict context from the URL bar. The destination URL, matched rule name, reason, category and any other verdict-bound field travel inside the encrypted payload. Nothing decodable to the visitor leaks into history or the Referer header.
  • The portal verifies and decrypts. A tampered p fails the integrity check and the portal returns 403.
  • Replay window is bounded. Requests where ts is more than ENGINE_SIGNATURE_MAX_AGE_SECONDS (default 300 s) old are rejected.
  • Connectors never see the secret. They are pure relays — they receive the pre-built URL from the engine and hand it to the browser as an HTTP 302.

The wire-level cryptographic construction is documented in the public protocol spec for portal integrators (not on this site).

Proceed-anyway round-trip

For verdicts with a CTA (warn and aup with ack_token present):

Browser            tls-terminator (:443)        Captive portal       Engine (:11225)
   │                    │                    │                     │
   │ GET /warned?p=…&v=1&ts=…                │                     │
   ├───────────────────►│ reverse-proxy ────►│ decrypt p     │
   │                    │                    │ → render warned    │
   │                    │                    │   page             │
   │◄───────────────────│◄───────────────────│                    │
   │                                                                │
   │ ▼ user clicks "Proceed anyway"                                 │
   │                                                                │
   │ GET /api/captive/ack?p=…&v=1&ts=…                              │
   ├───────────────────►│ reverse-proxy ────►│ server-side GET to │
   │                    │                    │ engine             │
   │                    │                    ├────────────────────►│ verify ack
   │                    │                    │                     │ record session
   │                    │                    │                     │ return 302 →
   │                    │                    │                     │   original URL
   │                    │                    │◄────────────────────│
   │                    │◄───────────────────│                     │
   │ 302 → origin URL   │                    │                     │
   │◄───────────────────│                    │                     │

Why this shape:

  • Single upstream from the terminator's perspective. Path-aware reverse-proxying lives in the portal, not in the terminator. Operators who need to swap the TLS terminator out only need to point at captive-portal:8000.
  • The browser never sees :11225. The shipped docker-compose.yml does not publish the engine's Control API to the host. The portal is the only path in.

Operator identity

Two distinct entities show up in the UI:

  • Software publisher — the author of the captive portal code, templates and design. Always Exosys Sàrl. Rendered as the footer copyright (Copyright © <year> Exosys Sàrl. All rights reserved.). Not environment-overridable.
  • Operator — the entity running this specific instance (the customer fronting EnforceGate with their hostname and TLS certificate). Surfaces in the privacy notice § 5 ("contacting …, the operator of this gateway") and as the display name on the CA install page.

The operator name is resolved at render time from three sources, in order:

  1. ENFORCEGATE_ORG_NAME — supplied by the installer (the same value baked into the bump CA's Subject DN O=). Rendered as plain text on both surfaces, because the installer doesn't carry a companion URL.
  2. BRAND_COMPANY — paired with BRAND_COMPANY_URL, so it renders as a linked anchor.
  3. Literal fallback — "your network administrator" in the privacy notice; "EnforceGate" on the CA install page.

In short: customers customise the operator identity; the software copyright stays attributed to Exosys.

Theming

The portal has two independent axes that combine freely:

Axis Decided by Mechanism
Palette (brand hue) Operator BRAND_PALETTE environment variable
Light / dark theme End user Topbar toggle, persisted in browser localStorage (falls back to OS prefers-color-scheme)

A single palette ships in light and dark flavours; the visitor can flip between light and dark regardless of which palette the operator configured.

Palette Notes
indigo Default. Cool indigo primary, neutral grey surfaces.
amber Warm amber primary, cream / dark-brown surfaces.
bsod Homage / parody palette inspired by the flat stop-screen aesthetic. Theme toggle flips between flat blue (light) and black (dark). Verdict pages render with a large :( accent.

Unknown palette values fall back silently to indigo.

Configuration

The portal is configured exclusively via environment variables forwarded from the bundle's .env. The operator-relevant subset:

Variable Purpose Default
ENFORCEGATE_CAPTIVE_SECRET_KEY Random 32+ char string for portal session-cookie signing. Required in production. (no default)
ENFORCEGATE_CAPTIVE_HOSTNAME FQDN clients reach the portal at. Drives the portal TLS leaf certificate CN/SAN. localhost (smoke-test only)
ENFORCEGATE_CAPTIVE_BASE_URL Public URL the engine puts in signed redirect tokens. Must match what the TLS terminator publishes. Used for the portal's own absolute-URL generation. (uses image default)
ENFORCEGATE_CAPTIVE_HTTP_PORT Host port for the TLS terminator's HTTP-redirect listener. 80
ENFORCEGATE_CAPTIVE_HTTPS_PORT Host port for the TLS terminator's HTTPS listener. 443
ENFORCEGATE_ORG_NAME Operator (customer) organisation name. See Operator identity. (unset)
ENFORCEGATE_CAPTIVE_BRAND_COMPANY Fallback operator name when ENFORCEGATE_ORG_NAME is unset. Exosys Sàrl
ENFORCEGATE_CAPTIVE_BRAND_PRODUCT Product name shown in the topbar and titles. EnforceGate vX
ENFORCEGATE_CAPTIVE_BRAND_PALETTE indigo, amber, or bsod. See Theming. indigo
ENFORCEGATE_CAPTIVE_LOCALE Default locale. en
ENFORCEGATE_CAPTIVE_SUPPORTED_LOCALES Comma-separated locales the portal will render. en,fr,de,it
ENFORCEGATE_CAPTIVE_DATABASE_URL SQLAlchemy URL. See Storage. image-defined
ENGINE_INTERNAL_URL Engine Control API URL the portal forwards /api/captive/ack to server-side. Empty string disables the route. https://enforcegate:11225
ENGINE_SIGNATURE_MAX_AGE_SECONDS Replay window for the ts parameter on signed redirects. 300 (5 minutes)
PRIVACY_POLICY_VERSION Version identifier recorded against each AUP acceptance so operators can prove which notice the visitor accepted. (a date-coded default)

Storage

The portal uses SQLAlchemy and refuses to boot without DATABASE_URL set. The captive portal upstream targets PostgreSQL as its production backing store (recommended for any deployment that needs to persist AUP acceptance records or future authentication state). The shipped IR bundle's docker-compose.yml defaults to an ephemeral SQLite file on container tmpfs — which wipes on every restart — so the standalone deployment boots out of the box with no external database to manage.

To switch to persistent storage, set ENFORCEGATE_CAPTIVE_DATABASE_URL in .env. SQLite on a named volume:

.env (SQLite on a named volume)
ENFORCEGATE_CAPTIVE_DATABASE_URL=sqlite:////app/data/captive-portal.db

Or PostgreSQL (recommended for production):

.env (PostgreSQL)
ENFORCEGATE_CAPTIVE_DATABASE_URL=postgresql+pg8000://portal:portal@captive-portal-db:5432/captive_portal

…and add a captive-portal-db service to the compose stack. See persistence for the broader volume model.