HAProxy
HAProxy terminates mTLS against the projected client CA bundle and runs a Lua action for every request that sends the DER certificate of the connection and the request line to mt-enforcer.
Start the enforcer
Pair the enforcer (once)
mt-enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer
# shows a pairing code; approve it in the portal (Services > your service > Pair gateway)
mt-enforcer forward-auth (haproxy)
mt-enforcer forward-auth --format haproxy --state /var/lib/mt-enforcer \
--listen 127.0.0.1:9191 --bundle-out /etc/mt/client-ca.pemHAProxy needs Lua support (USE_LUA=1, included in the official images) for core.httpclient.
haproxy.cfg
services/enforcer/integrations/haproxy/haproxy.cfg
# Mutual Trust with HAProxy (tested with HAProxy 3.4.7, USE_OPENSSL=1 USE_LUA=1).
#
# HAProxy terminates mTLS against the client CA bundle projected by mt-enforcer
# and runs the mt-authz Lua action for every request. Inbound x-mt-* headers are
# deleted first; the request only proceeds when the enforcer allowed it.
#
# mt-enforcer forward-auth --format haproxy --state /var/lib/mt-enforcer \
# --listen 127.0.0.1:9191 --bundle-out /etc/mt/client-ca.pem
#
# /etc/haproxy/certs/server.pem holds your server certificate chain and key.
global
lua-load /etc/haproxy/mt-authz.lua
log stdout format raw local0 info
defaults
mode http
log global
option httplog
timeout connect 2s
timeout client 30s
timeout server 30s
frontend mtls
bind :8443 ssl crt /etc/haproxy/certs/server.pem ca-file /etc/mt/client-ca.pem verify required alpn h2,http/1.1
http-request del-header ^x-mt- -m reg
http-request del-header x-ssl-client-der
http-request del-header ^(x-http-method-override|x-http-method|x-method-override|x-original-url|x-rewrite-url|x-original-method|x-original-uri)$ -m reg
http-request set-var(txn.mt_enforcer_url) str(http://127.0.0.1:9191/authz)
http-request lua.mt-authz
http-request deny deny_status 503 content-type application/json string '{"code":"enforcer_unavailable"}' unless { var(txn.mt_allowed) -m bool }
http-response set-header x-mt-request-id %[var(txn.mt_request_id)]
http-response set-header x-mt-policy-revision %[var(txn.mt_revision)]
default_backend origin
backend origin
server origin origin:9001 # private network onlymt-authz.lua
services/enforcer/integrations/haproxy/mt-authz.lua
-- Mutual Trust authorization action for HAProxy (tested with HAProxy 3.4.7).
--
-- For every request, asks mt-enforcer (forward-auth, --format haproxy) whether
-- the verified client certificate of this connection may call this method and
-- path. The request to the enforcer is built from scratch: the DER certificate
-- from the TLS layer, the original method and path, and the trace context.
--
-- Allow: the verified identity is set on the request to the backend.
-- Deny: the enforcer's status and JSON problem are returned to the client.
-- Any failure leaves txn.mt_allowed unset, and haproxy.cfg denies the request.
local function first(headers, name)
local v = headers and headers[name]
if v == nil then return nil end
if type(v) == "table" then return v[0] or v[1] end
return v
end
core.register_action("mt-authz", { "http-req" }, function(txn)
local url = txn:get_var("txn.mt_enforcer_url") or "http://127.0.0.1:9191/authz"
local headers = {
["x-original-method"] = { txn.f:method() },
["x-original-uri"] = { txn.f:pathq() },
}
local der = txn.f:ssl_c_der()
if der ~= nil and #der > 0 then
headers["x-ssl-client-der"] = { txn.c:base64(der) }
end
local tp = txn.f:req_hdr("traceparent")
if tp ~= nil and tp ~= "" then headers["traceparent"] = { tp } end
local res = core.httpclient():get({ url = url, headers = headers, timeout = 2000 })
if res == nil or res.status == nil or res.status == 0 then
txn:set_var("txn.mt_reason", "enforcer_unavailable")
return
end
local request_id = first(res.headers, "x-mt-request-id") or ""
if res.status == 200 then
local principal = first(res.headers, "x-mt-principal")
if principal == nil or principal == "" then
txn:set_var("txn.mt_reason", "enforcer_response_invalid")
return
end
txn.http:req_set_header("x-mt-principal", principal)
txn.http:req_set_header("x-mt-grant", first(res.headers, "x-mt-grant") or "")
txn.http:req_set_header("x-mt-request-id", request_id)
txn:set_var("txn.mt_request_id", request_id)
txn:set_var("txn.mt_revision", first(res.headers, "x-mt-policy-revision") or "")
txn:set_var("txn.mt_allowed", true)
return
end
local status = res.status
if status ~= 401 and status ~= 403 then status = 403 end
local reply = txn:reply({
status = status,
headers = {
["content-type"] = { "application/json" },
["x-mt-request-id"] = { request_id },
["x-mt-reason"] = { first(res.headers, "x-mt-reason") or "denied" },
},
body = res.body or "",
})
txn:done(reply)
end)How it fails closed
The request proceeds only when the Lua action set txn.mt_allowed; a denial is returned by the action with the enforcer's status and JSON problem, and anything else (enforcer unreachable, an unexpected answer, a Lua error) ends in the http-request deny rule with 503. Inbound x-mt-* headers are deleted before the action runs.
Run with Docker Compose
services/enforcer/integrations/haproxy/docker-compose.yaml
# HAProxy + mt-enforcer (Lua action).
# docker compose run --rm enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer
# docker compose up -d
services:
enforcer:
build: { context: ../.., dockerfile: Dockerfile }
image: mt-enforcer:1.0.0
command: ["forward-auth", "--format", "haproxy", "--state", "/var/lib/mt-enforcer", "--listen", "127.0.0.1:9191",
"--admin", "0.0.0.0:9464", "--bundle-out", "/var/run/mt/bundle/client-ca.pem"]
ports: ["8443:8443", "9464:9464"]
volumes: ["mt-state:/var/lib/mt-enforcer", "mt-bundle:/var/run/mt/bundle"]
healthcheck: { test: ["CMD", "/mt-enforcer", "healthcheck"], interval: 5s, retries: 30 }
haproxy:
image: haproxy:3.4.7-alpine@sha256:b9fe316f718d5015c5cfebd8ca019591f3e44656f61d93cb8b59a1028961e530
network_mode: "service:enforcer"
depends_on: { enforcer: { condition: service_healthy } }
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- ./mt-authz.lua:/etc/haproxy/mt-authz.lua:ro
- ./certs:/etc/haproxy/certs:ro
- mt-bundle:/etc/mt:ro
origin:
image: ${ORIGIN_IMAGE:-ghcr.io/your-org/orders-api:latest}
volumes: { mt-state: {}, mt-bundle: {} }Verify
Verify with the enrolled workload identity
mt enroll --token <token from portal> --api https://api.mutualtrust.io
mt call https://orders-api.example.com:8443/orders/1
# 200, and your origin receives x-mt-principal: urn:mutualtrust:workload:...
mt call https://orders-api.example.com:8443/admin/export
# 403 {"code":"route_not_permitted", ...}
# any other client: the files are in the workload directory that mt status prints
W="$(dirname "$(mt status --json | jq -r .files.chain)")"
curl --cert "$W/chain.pem" --key "$W/key.pem" https://orders-api.example.com:8443/orders/1Tested