Architecture¶
The EnforceGate vX solution is engineered to be lightweight, extensible, high-performing, and secure-by-default. It requires no specialised hardware and offers extensive customisation. The core engine and connector binaries are implemented in modern C and C++ for high performance; the captive portal is a Python application; everything ships as hardened Linux containers images with a supervised process tree.
Key components¶
| Component | Role |
|---|---|
enforcegate-engine |
Policy evaluation. Compiles ACLs into highly optimised in-memory match structures backed by a DuckDB session store. Speaks the proprietary Defendr binary protocol to connectors. |
enforcegate-squid-connector |
Two distinct Squid helper roles. url_rewrite_program receives every URL Squid sees, forwards it over Defendr/TLS to the engine, and translates the verdict back to Squid. external_acl_type (--acl invocation, new in 2026.35.0) returns a peek-step splice / bump / terminate verdict for SslBump-handled connections — see Pinned destinations. |
| Squid 7.x | HTTP/HTTPS proxy with ssl-bump capabilities. Listens on :3128. |
| Captive portal | Python sidecar that renders block, warn and AUP verdict pages. Hosts the self-service CA install page (/ca, /ca.crt) for unmanaged clients. |
| TLS terminator | Fronts the captive portal with HTTPS on :443 (and an HTTP→HTTPS redirect on :80). Uses a leaf certificate signed by the deployment's bump CA. |
enforcegate-toolbox (optional) |
Sandboxed sidecar with a bash + Python environment and the standard EnforceGate CLI tooling. Runs operator-supplied scripts on a schedule (canonical use case: category-based domain-list refresh) and hands generated .list / .policy files to the engine over the shared volume. Off by default; opt-in at install time or via eghost toolbox enable. See Toolbox. |
egctl |
Operator CLI for engine administration over the Control API. |
egpolicy |
Operator CLI for compiling ACL rule files into the engine's policy store. |
Deployment shape — standalone bundle¶
The primary customer-facing deliverable is the standalone bundle: a single docker-compose.yml running three core services over four named volumes, plus the optional fifth toolbox sidecar (off by default; activated by the compose toolbox profile when ENFORCEGATE_TOOLBOX_ENABLED=true).
flowchart LR
user(Client device)
ops(Operator)
subgraph compose["docker compose standalone_default"]
direction LR
eg["enforcegate<br/>(engine + Squid + connector)<br/>:3128"]
tls["tls-terminator<br/>:80, :443"]
portal["captive-portal<br/>(Python)<br/>:8000 (internal)"]
end
cfg[(enforcegate-config<br/>/etc/enforcegate)]
data[(enforcegate-data<br/>/var/lib/enforcegate)]
shared[(enforcegate-shared<br/>/etc/enforcegate-shared)]
tls[(enforcegate-tls<br/>/data)]
user -- HTTP/HTTPS proxy --> eg
user -- verdict redirect :80/:443 --> tls
tls -- reverse-proxy --> portal
tls -- /api/captive/ack --> portal
portal -- server-side ack --> eg
ops -- docker exec egctl --> eg
eg --- cfg
eg --- data
eg --- shared
portal -. read-only .- shared
tls --- tlsvol
tls -. read-only .- shared
What each service does¶
enforcegate— the all-in-one engine container. On first boot it self-initialises (keys, certificates, default policy) and then runs the policy engine alongside the Squid proxy that feeds traffic to it.tls-terminator— a TLS terminator, configured to redirect plain HTTP:80to HTTPS:443and to reverse-proxy everything else tocaptive-portal:8000. The leaf TLS certificate is the engine-issued portal cert signed by the bump CA, read from the shared volume.captive-portal— Python application served on internal port8000(never published to the host). Verifies the encrypted redirect payload using the same shared secret the engine uses to produce it, and renders the verdict page in the visitor's language.toolbox(optional sidecar) — Debian-based sandbox with bash + Python + the standard EnforceGate CLI tooling. The toolbox runs arbitrary operator code (operator scripts,pip install, third-party binaries) and glibc maximises compatibility for that workload — making it the one shipped image on a glibc base; every other container runs Exosys code against pinned dependencies on Alpine. Activated by the composetoolboxprofile (ENFORCEGATE_TOOLBOX_ENABLED=truein.env). Operators use it for scheduled category-list refresh, ad-hoc URL/domain investigation, and other scripting against the deployment — see Toolbox. Has its own writable volume and hands artefacts to the engine over the shared volume; never gets the engine's bump CA or license credentials.
Listeners¶
| Port | Bound on | Purpose |
|---|---|---|
| 3128 | Host (:3128) |
Squid HTTP/HTTPS forward proxy. |
| 80 | Host (:80) |
TLS terminator — permanent redirect to :443 (plus the /ca, /ca.crt self-service routes when bump mode is active). |
| 443 | Host (:443) |
TLS terminator for the captive portal. |
| 8000 | Compose network only | Captive portal upstream. |
| 11224 | Container loopback | Engine Defendr listener — connector ⇄ engine traffic. |
| 11225 | Compose network | Engine Control API — egctl (via docker exec) + portal server-side ack proxy. Not published to the host by the shipped compose. |
Encrypted communication¶
The engine and its connectors communicate over the proprietary Defendr binary protocol. The protocol is transport-agnostic but in the standalone bundle is run over TLS on tcp/11224 (loopback only). Mutual peer authentication is performed with a 32-character shared key that the generate-engine-key boot one-shot mints on first boot and writes into both engine.conf ([connectors.local].key) and squid-connector.conf ([engine.remote].key) — operators can pin a specific key by bind-mounting engine.conf and squid-connector.conf instead.
Cryptographic primitives are implemented using OpenSSL. The engine's Defendr listener serves a self-signed X.509 certificate generated by the generate-ssl-certs one-shot on first boot.
Block / warn redirect flow¶
The path from a user browsing through the proxy to a policy-matched URL, ending with the browser holding a redirect to the captive portal. The detail below assumes ENFORCEGATE_SSL_INSPECT=bump (full HTTPS decryption); in off and peek modes the engine cannot rewrite HTTPS URLs at the application layer and the redirect flow only applies to plain HTTP.
From 2026.35.0 onwards, Squid additionally consults the connector's external_acl_type helper at the SslBump peek step — before the bumped TLS handshake below — to decide whether the destination should be spliced (passed through unchanged, hostname-only), bumped (inspected, the flow below), or terminated (handshake refused). The verdict comes from the engine's pin: rules; a host with no pin: match defaults to bump. See Pinned destinations for the operator-side knob and [ssl_bump_acl].fail_action for the engine-unreachable fallback.
Browser Squid (:3128) Connector Engine (:11224)
│ │ │ │
│ CONNECT example.com:443 │ │
├──────────────────────►│ │ │
│ 200 Connection Established │ │
│◄──────────────────────│ │ │
│ │
│ TLS handshake (bumped — Squid mints a forged leaf for │
│ example.com signed by the deployment's bump CA) │
│◄═════════════════════►│ │ │
│ │
│ GET / HTTP/1.1 (now inside the bumped TLS tunnel) │
├──────────────────────►│ │ │
│ │ stdin line: URL + 17 TAB-separated extras │
│ │ (client IP, MAC, user, SNI, UA, …) │
│ ├─────────────────►│ │
│ │ │ defendr request with │
│ │ │ HTTPAttributes │
│ │ ├───────────────────────►│
│ │ │ │ policy match
│ │ │ │ → verdict 310
│ │ │ │ (warn)
│ │ │ │ → encrypt
│ │ │ │ inner payload
│ │ │ │ with shared
│ │ │ │ secret
│ │ │ defendr response with │
│ │ │ redirect URL │
│ │ │◄───────────────────────│
│ │ "OK status=302 url=https://localhost/warned?p=…"
│ │◄─────────────────│ │
│ HTTP/1.1 302 Found (inside the bumped tunnel) │
│ Location: https://localhost/warned?p=…&v=1&ts=… │
│◄──────────────────────│ │
Policy evaluation order is lowest rule id wins — operators control precedence with the two-digit filename prefix (see policies). A rule with a closed time-window: is treated as non-existent for that request and falls through to the next rule, so the same precedence model composes with time-scheduled rules without special-casing.
Proceed-anyway acknowledgement¶
When the user lands on the warn page and clicks Proceed anyway, the click hits the portal-relative path /api/captive/ack on the same origin (terminator → portal). The portal then makes a server-side call to the engine's Control API on the compose network — the browser never speaks directly to the engine.
Browser tls-terminator (:443) Captive portal Engine (:11225)
│ │ │ │
│ GET /warned?p=…&v=1&ts=… │ │
├─────────────────────►│ reverse-proxy │ │
│ ├──────────────►│ decrypt p │
│ │ │ → render warned │
│ │◄──────────────│ page with CTA │
│◄─────────────────────│ │ │
│ │
│ ▼ user clicks "Proceed anyway" │
│ │
│ GET /api/captive/ack?p=…&v=1&ts=… │
├─────────────────────►│ reverse-proxy │ │
│ ├──────────────►│ /api/captive/ack │
│ │ │ route — server- │
│ │ │ side urllib GET │
│ │ │ to engine 11225 │
│ │ ├─────────────────►│ decrypt
│ │ │ │ + record
│ │ │ │ session
│ │ │ │ → 302 to
│ │ │ │ original URL
│ │ │◄─────────────────│
│ │◄──────────────│ │
│ 302 → origin URL │ │ │
│◄─────────────────────│ │ │
The key invariants of this shape:
- The browser only ever speaks to the TLS terminator. The engine's Control API listener
:11225is reachable on the compose network from the portal container, butdocker-compose.ymldoes not publish it to the host. - The TLS terminator is bare with one upstream (
captive-portal:8000) — it does not know any of the portal's URL space. - Operators distribute one CA — the bump CA generated on first boot. It signs the per-host forged certs Squid mints during bump, the engine's Defendr listener cert, and the portal's TLS leaf served by the terminator.
Persistence¶
Four named volumes back the standalone bundle, plus an optional fifth that only exists when the toolbox sidecar is enabled:
| Volume | Container path | Holds |
|---|---|---|
enforcegate-config |
/etc/enforcegate/ |
engine.conf, squid-connector.conf, egctl.conf, egpolicy.conf, passwd, rules.d/*.policy, ssl/, ssl-bump/{ca.pem,ca.key}, license/, audit/. |
enforcegate-data |
/var/lib/enforcegate/ |
engine.db (compiled policy DuckDB). |
enforcegate-shared |
/etc/enforcegate-shared/ |
captive-portal-secret (engine ⇄ portal shared secret), portal.{crt,key} (portal TLS leaf signed by the bump CA), and the toolbox's handoff areas — lists/*.list (domain lists referenced by match-domain-list: directives) and rules.d/*.policy (machine-generated policy files loaded via [policy].shared_path). |
enforcegate-tls |
/data |
TLS-terminator runtime data dir — ACME state, internal CA, lock files. Reserved for future ACME use. |
enforcegate-toolbox-scripts (optional) |
/var/lib/enforcegate-toolbox/ |
Operator-writable area for the toolbox sidecar — scripts/, cron.d/, repos/, state/, imported SSH deploy keys and CA certs, and the installer-written .creds for engine control-API auth. Survives container recreate and image upgrades. |
Image defaults are seeded into the volumes on first boot from /opt/enforcegate/skel/ (file-level no-clobber) and section-merged on subsequent upgrades. See persistence for the full operator model.
The rules.d/*.policy files inside enforcegate-config have a two-layer persistence model. Every successful reload writes a timestamped snapshot to [policy].backup_path — this is the recovery substrate, always present, always usable for request policy rollback. On top of that, deployments that have run git init inside [policy].path get an additional audit-trail layer: the engine records every reload as a git commit before the snapshot is taken, and exposes the history through dedicated operator verbs (show policy log, show policy blame, show policy commit, show policy fingerprint, show policy tags). The two layers coexist; the audit layer is purely additive and can be enabled or disabled without touching snapshot semantics. See policy audit and history for the operator workflow.
Supply-chain security¶
- In-image SHA-256 manifest: every release image carries
/opt/enforcegate/manifest.sha256covering the bundled binaries. Anintegrity-checkboot one-shot re-hashes them before any long-running service starts; a long-runningintegrity-watcherre-hashes them every 5 minutes thereafter (overridable viaENFORCEGATE_INTEGRITY_WATCH_INTERVAL). Mismatch crashes the container. - Hardware-anchored release signing: the image digest, the
docker-compose.yml, and every binary that goes into the image are signed with an ECC P-256 key held on a dedicated hardware key. The release private key never exists as a file on disk. Customers can verify withcosign verify --key exosys-release.pub …. - Read-only root filesystem plus
cap_drop: ALL(re-adding only the six capabilities required),security_opt: no-new-privileges, andpids_limit: 256— defence in depth against post-RCE attackers.