Skip to content

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 :80 to HTTPS :443 and to reverse-proxy everything else to captive-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 port 8000 (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 compose toolbox profile (ENFORCEGATE_TOOLBOX_ENABLED=true in .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 stepbefore 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 :11225 is reachable on the compose network from the portal container, but docker-compose.yml does 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.sha256 covering the bundled binaries. An integrity-check boot one-shot re-hashes them before any long-running service starts; a long-running integrity-watcher re-hashes them every 5 minutes thereafter (overridable via ENFORCEGATE_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 with cosign 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, and pids_limit: 256 — defence in depth against post-RCE attackers.