Configuration Reference
These are the global settings in exfilguard.toml and optional
config.d/*.toml fragments. ExfilGuard reads them at startup.
Core Settings
These settings are required.
| Field | Type | Required | Description |
|---|---|---|---|
listen |
String | Yes | Listen address and port (e.g., "127.0.0.1:3128") |
proxy_protocol |
String | "off" |
PROXY protocol mode: "off", "optional", or "required" |
proxy_protocol_allowed_cidrs |
Array | None | CIDR allowlist for peers allowed to send PROXY headers (required when proxy_protocol is "optional" or "required") |
proxy_protocol_max_pending_connections |
usize | 1024 | Maximum connections from allowlisted peers concurrently awaiting PROXY header processing |
ca |
Table | Yes | Explicit TLS interception CA source: builtin, files, or vault |
clients |
Path | Yes | Path to clients configuration file |
policies |
Path | Yes | Path to policies configuration file |
clients_dir |
Path | No | Directory containing additional client config files (*.toml) |
policies_dir |
Path | No | Directory containing additional policy config files (*.toml) |
Note
Relative paths are resolved from the directory containing the main config file.
Note
Use ExfilGuard as an explicit proxy. Clients should know they are talking to one.
Note
When proxy_protocol is "optional" or "required", ExfilGuard
auto-detects PROXY protocol v1 or v2 headers for peers in
proxy_protocol_allowed_cidrs.
In "optional" mode, peers outside that allowlist are treated as normal
client connections.
In "required" mode, peers outside that allowlist are rejected before
HTTP parsing, and allowlisted peers must send a valid PROXY header.
Allowlisted peers are trusted to send the complete PROXY header promptly as
the first bytes of a backend connection. ExfilGuard bounds this setup with
request_header_timeout and proxy_protocol_max_pending_connections.
Excess pending connections are closed without spawning a handler task.
Note
Set proxy_protocol_allowed_cidrs when PROXY protocol is enabled.
Note
ExfilGuard reads exfilguard.toml and config.d/*.toml only at startup.
SIGHUP reloads only the client and policy data read from the configured clients,
clients_dir, policies, and policies_dir paths. If you change fields
in exfilguard.toml itself, including listen, metrics, cache, TLS,
logging, and timeout settings, restart the server.
Treat all configured client and policy files as one generation: finish
replacing every file before sending SIGHUP, and do not modify them during
the reload. ExfilGuard validates and atomically publishes the resulting
in-memory snapshot; if loading fails, the previous snapshot remains active.
Global Configuration Fragments
ExfilGuard reads regular *.toml files from a config.d directory beside the
selected main configuration file. Fragments load in lexicographic filename
order after the main file and before EXFILGUARD__* environment overrides.
Later values replace earlier values, and fragments may contain only the fields
they override.
For the default /etc/exfilguard/exfilguard.toml, a long-polling override could
be written as /etc/exfilguard/config.d/50-long-polls.toml:
All relative paths, including paths set by fragments, are resolved from the directory containing the main configuration file. Changing a fragment requires restarting ExfilGuard.
Client Connection Admission
Every configured client has a simultaneous downstream connection budget. The default is 1,024 connections and can be overridden on an individual client:
[[client]]
name = "build-workers"
cidr = "10.42.16.0/27"
policies = ["build-egress"]
max_connections = 2048
The budget counts ordinary HTTP keep-alive connections, raw CONNECT tunnels,
and inspected CONNECT sessions. A CIDR client shares one budget across every
machine it matches. When the budget is full, ExfilGuard closes new connections
without evicting established ones. max_connections must be greater than zero.
Client and policy reloads apply a changed limit to new admission without resetting active connection accounting. An existing connection's permit remains charged to the client identity assigned when it was accepted, even if a reload later maps its peer address to another client. Subsequent requests still use the latest client mapping and policies. The limit is an operational resource guard, not a retroactively reconciled tenant quota.
TLS / Certificate Settings
These settings control TLS interception and leaf certificate generation.
| Field | Type | Default | Description |
|---|---|---|---|
leaf_ttl |
u64 | 86400 | TLS certificate leaf TTL in seconds (must be > 0) |
leaf_cache_capacity |
usize | 4096 | Maximum number of generated TLS leaves retained in memory (must be > 0) |
leaf_mint_concurrency |
usize | 4 | Maximum concurrent blocking TLS leaf mint jobs (must be > 0) |
CA Sources
ExfilGuard uses a root CA and a path-length-zero intermediate CA. The
intermediate signs generated leaves, ExfilGuard sends Leaf -> Intermediate ->
Root, and clients trust the root. Choose the component that owns this
lifecycle explicitly; ExfilGuard never guesses from the files it finds and
never falls back to another source. In every mode, leaf validity is capped by
the active intermediate's remaining validity, with a safety margin; issuance
stops when too little safe lifetime remains.
ExfilGuard enforces the CA semantics needed for arbitrary policy-selected leaf
names. It rejects name-constrained hierarchies and critical certificate
extensions other than basicConstraints and keyUsage; accepting either would
require applying additional constraints during every leaf issuance. Noncritical
informational extensions remain permitted.
builtin
On the first start with an empty directory, ExfilGuard generates certificates valid for approximately ten years and persists exactly:
The root key exists only while the hierarchy is generated and is never written
to disk. First-run creation is a recoverable filesystem transaction: ExfilGuard
stages and validates the complete hierarchy, publishes it durably, and resumes
or discards only its own recognizable staging state after interruption.
Operator-created incomplete material still fails startup rather than silently
creating a new trust anchor. Consequently, builtin cannot renew or replace
its intermediate under the same root. Before the hierarchy expires, or after a
key compromise, generate a new hierarchy and distribute its new root to clients.
The Debian package selects builtin, creates /var/lib/exfilguard/ca as
exfilguard:exfilguard mode 0700, and runs the service with UMask=0077.
It therefore works on a clean installation without an operator permission
step.
files
files treats all three artifacts above as externally managed. ExfilGuard
neither creates nor renews them. Provision a new intermediate certificate and
matching key as one generation, replace the directory contents safely, and
restart ExfilGuard. The new process starts with an empty in-memory leaf cache.
Startup validates that the intermediate key matches its certificate, the
intermediate chains to the root, both certificates are currently valid, and
CA basic constraints, path length, key usage, and issuer validity allow safe
leaf issuance. root.key is always rejected: neither file-backed source uses
it, and leaving a root signing key beside the online intermediate needlessly
increases exposure.
File permissions
For builtin and files, ExfilGuard enforces owner-only control at every
startup:
- The directory must be real, not a symlink, owned by the process UID, and
mode
0700or read-only mode0500. - Every CA file must be a regular, non-symlink file owned by the process UID.
intermediate.keymust have mode0600or read-only mode0400.- Certificates must be owner-readable and non-executable, and must not be
writable by group or other users. Modes
0600,0644, and read-only equivalents are suitable.
Startup reports a remediation command instead of changing provisioned files. For the packaged service, a typical repair is:
sudo chown -R exfilguard:exfilguard /var/lib/exfilguard/ca
sudo chmod 0700 /var/lib/exfilguard/ca
sudo chmod 0600 /var/lib/exfilguard/ca/intermediate.key
sudo chmod 0644 /var/lib/exfilguard/ca/root.crt \
/var/lib/exfilguard/ca/intermediate.crt
Package upgrades restore the CA directory ownership and mode but deliberately do not rewrite operator-provisioned files.
vault
This integration is for HashiCorp Vault's PKI secrets engine, including Vault Enterprise namespaces. HCP Vault Secrets is a different product and API.
Vault mode generates a fresh intermediate key in process memory and submits its CSR to the configured selected Vault PKI issuer. The intermediate key and all generated leaf keys remain in memory. ExfilGuard authenticates only when it needs a signing operation, discards direct-auth tokens after that request, and renews the whole issuer generation before expiry. The immutable issuer and its empty leaf cache become active in one atomic switch.
[ca]
source = "vault"
address = "https://vault.internal.example:8200"
tls_ca_cert = "/etc/exfilguard/vault-tls-ca.crt"
# namespace = "team-a"
pki_mount = "pki"
issuer = "exfilguard-parent"
expected_root_certs = "/etc/exfilguard/exfilguard-roots.pem"
intermediate_ttl = 2592000 # 30 days
renewal_threshold = 1296000 # renew with 15 days remaining
request_timeout = 10
# tls_client_cert = "/etc/exfilguard/vault-client.crt"
# tls_client_key = "/etc/exfilguard/vault-client.key"
[ca.auth]
method = "approle"
mount = "approle"
role_id = "00000000-0000-0000-0000-000000000000"
secret_id_file = "/etc/exfilguard/vault-secret-id"
pki_mount defaults to pki, intermediate_ttl to 30 days,
renewal_threshold to 15 days, and request_timeout to 10 seconds. The
threshold must be shorter than the requested lifetime. Set it early enough to
cover a realistic Vault outage and operator response window.
The renewal scheduler rechecks certificate expiry against wall-clock time at least hourly rather than sleeping for the complete multi-day interval. This keeps renewal responsive after host suspend or a wall-clock adjustment. Failed renewals retry with jittered exponential backoff from 5 seconds to 15 minutes.
Set tls_server_name when address uses an IP literal but the Vault server
certificate names a DNS host. It controls certificate verification while the
client still connects to the configured IP.
ExfilGuard calls only:
Vault must support selected issuers. ExfilGuard does not fall back to the
older default-issuer endpoint because that could select a different trust
hierarchy. Give the AppRole or token update permission only on that exact
path. If possible, use a selected signing issuer constrained to pathlen:1 so
the ExfilGuard intermediate is cryptographically limited to pathlen:0.
expected_root_certs is a PEM bundle independent of the TLS CA used to reach
Vault. ExfilGuard accepts the returned issuer only when it chains to one of
these explicitly pinned roots and passes the same key, constraint, usage, and
validity checks as file mode. Put old and new roots in the bundle during a
planned root rotation, switch the Vault issuer, then remove the old root after
the overlap period. Changing the bundle requires restarting ExfilGuard.
Vault supports three authentication transports:
approleperforms a just-in-time login at/v1/auth/<mount>/login. Keeprole_idin TOML and the durable SecretID in the dedicated owner-onlysecret_id_file. Prefer a narrowly scoped AppRole, CIDR bindings where addresses are stable, no default policy, and a one-use token for the signing request. The reusable SecretID remains durable signing authority: keeping CA keys in memory does not make a compromised host or credential harmless.token_filereads a token supplied and renewed by another component:
proxysends the request without a token so a local Vault Proxy can attach its auto-auth token. Pointca.addressat its restricted listener:
Credential files must be regular, non-symlink, owned by the ExfilGuard process UID, and readable only by that owner. Literal SecretIDs and tokens are not accepted in TOML or command-line arguments. Vault requests do not follow redirects or inherit ambient HTTP proxy settings, preventing credentials from being forwarded to another origin or back through ExfilGuard itself. Vault connections must use HTTPS; plaintext HTTP is accepted only for a loopback IP listener such as a local Vault Proxy.
Vault mode has a deliberate availability dependency: after every process
restart, Vault must be reachable because the old intermediate key was never
persisted. If a renewal fails while the active issuer is still valid,
ExfilGuard keeps serving with it, retries with bounded backoff, and exposes the
failure to Prometheus. An invalid Vault response never replaces the issuer. If
no usable issuer exists, inspected HTTPS fails closed; it never falls back to
builtin, files, direct forwarding, or CONNECT tunneling. Renewal failures
do not crash a process that still has a valid issuer.
Migrating from ca_dir
The old top-level ca_dir setting is not accepted. Replace it with one of the
explicit configurations above. Existing installations must also remove
root.key from the active CA directory before starting this version. If it is
a real root key that must be retained, move it to appropriately protected
offline storage; ExfilGuard never reads it. Remove the older compatibility
workaround that copied intermediate.key to root.key.
For an existing locally generated directory, the direct migration is:
Retain root.crt, intermediate.crt, and intermediate.key, remove
root.key, restore the documented ownership and modes, then restart.
Logging Settings
| Field | Type | Default | Description |
|---|---|---|---|
log |
String | "json" |
Log format: "json" or "text" |
log_queries |
Boolean | false | Whether to log each request query |
Timeout Settings
All timeout values are in seconds. Use 0 to disable request_total_timeout and
connect_tunnel_max_lifetime.
| Field | Type | Default | Description |
|---|---|---|---|
dns_resolve_timeout |
u64 | 2 | Maximum time to resolve DNS for upstream hosts |
upstream_connect_timeout |
u64 | 5 | Maximum total time for one upstream TCP connection across all resolved addresses |
tls_handshake_timeout |
u64 | 10 | Maximum time for TLS handshakes (client or upstream) |
request_header_timeout |
u64 | 10 | Maximum time to read an HTTP/1 request line + headers or the downstream HTTP/2 connection preface |
request_body_idle_timeout |
u64 | 30 | Maximum idle time between request body reads/writes |
response_header_timeout |
u64 | 60 | Maximum total time from waiting for the first upstream response head through receiving the final response head |
response_body_idle_timeout |
u64 | 60 | Maximum idle time between response body reads/writes |
request_total_timeout |
u64 | 0 | Maximum total time from accepting a complete request head through policy/cache work, upstream setup, and full response delivery (0 disables) |
client_keepalive_idle_timeout |
u64 | 30 | Idle time before closing an HTTP/1 keep-alive connection or an HTTP/2 connection with no active request streams |
connect_tunnel_idle_timeout |
u64 | 60 | Maximum time with no successful relay progress in either direction of a raw CONNECT tunnel; also bounds individual tunnel writes and setup/shutdown operations |
connect_tunnel_max_lifetime |
u64 | 0 | Maximum lifetime for CONNECT tunnels (0 disables) |
For downstream HTTP/2, the connection is request-idle whenever it has no
active stream task. The client must produce its next complete request within
client_keepalive_idle_timeout; PING and other control traffic do not extend
that period. This also bounds an incomplete HTTP/2 header block while no other
stream is active. Active streams remain governed by the request-body,
response, and optional total-request timeouts rather than the connection-idle
timer.
The total-request timeout begins after a complete inner HTTP request head has
been accepted. It caps cache lookup and delivery, DNS/TCP/TLS upstream setup,
request upload, retries, and response delivery. Phase-specific timeouts still
apply, so whichever deadline expires first wins. Before a final response has
started, expiry returns 504 Gateway Timeout; after response delivery has
started, ExfilGuard closes or resets the request instead of appending another
response. Outer CONNECT setup, tunnel lifetime, and cleanup after response
delivery use their own limits.
For HTTP/2 cacheable responses, response_body_idle_timeout also bounds each
cache file operation on the response path. A storage error or timeout abandons
the cache copy while response forwarding continues.
Request Size Limits
All size values are in bytes. Header limits must be greater than 0.
Set max_request_body_size = 0 to disable the global request-body cap.
| Field | Type | Default | Description |
|---|---|---|---|
max_request_header_size |
usize | 32768 (32 KiB) | Maximum HTTP request header size, including bumped HTTP/2 header lists |
max_response_header_size |
usize | 32768 (32 KiB) | Maximum HTTP response header size; for HTTP/1 this is shared by all informational and final heads, and for HTTP/2 it limits the upstream header list |
max_request_body_size |
usize | 0 (unlimited) | Maximum HTTP request body size during forwarding (0 disables the limit) |
Connection Pool
| Field | Type | Default | Description |
|---|---|---|---|
upstream_pool_capacity |
usize | 32 | Maximum number of upstream connections to pool (must be >= 1) |
HTTP/2
| Field | Type | Default | Description |
|---|---|---|---|
http2_max_concurrent_streams |
u32 | 100 | Maximum concurrent bumped downstream HTTP/2 streams per connection (must be >= 1) |
Metrics
| Field | Type | Default | Description |
|---|---|---|---|
metrics_listen |
String | None | Optional listen address (e.g., "127.0.0.1:9090") to serve Prometheus metrics at /metrics |
metrics_tls_cert |
Path | None | PEM certificate chain to enable HTTPS for /metrics |
metrics_tls_key |
Path | None | PEM private key matching metrics_tls_cert |
ExfilGuard exports counters and histograms for per-client and per-policy decisions, latency, cache activity, and upstream reuse, plus gauges for current downstream connections globally and by client, in-flight requests, CONNECT tunnels, bumped TLS sessions, active HTTP/2 streams, upstream connections, cache usage, and the last successful policy reload time. Rejected downstream connections are counted by configured client name.
requests_method_total uses a bounded method label: the standard HTTP
methods are reported by name and all extension methods are aggregated as
OTHER. Access logs retain the exact method.
CA lifecycle metrics are intentionally low-cardinality:
| Metric | Description |
|---|---|
ca_source_info{source} |
Selected builtin, files, or vault source |
ca_certificate_not_after_timestamp_seconds{certificate} |
Root or active intermediate expiry as a Unix timestamp |
ca_issuer_usable |
1 while the active issuer can safely mint a leaf, otherwise 0 |
ca_issuer_generation |
In-process issuer generation; increments on a successful Vault replacement |
ca_vault_renewal_attempts_total{result,reason} |
Vault renewal outcomes with bounded reason labels |
ca_vault_last_renewal_attempt_timestamp_seconds |
Last Vault renewal attempt time |
ca_vault_last_renewal_success_timestamp_seconds |
Last successful Vault renewal time |
Scrape these metrics in every CA mode: certificate expiry matters for
builtin and files even though ExfilGuard does not renew those sources. See
prometheus-alerts.yml for example expiry, usability,
and Vault failure alerts. Adjust the warning windows to your certificate TTL
and incident-response policy.
Cache Settings
Response caching is opt-in per rule. The settings here configure the shared cache storage.
| Field | Type | Default | Description |
|---|---|---|---|
cache_dir |
Path | None | Directory for response cache storage |
cache_max_entry_size |
u64 | 104857600 (100 MiB) | Maximum size of an entry and aggregate in-progress response bodies |
cache_max_entries |
usize | 10000 | Maximum number of cached responses (LRU) |
cache_total_capacity |
u64 | 1073741824 (1 GiB) | Capacity of completed cache entries |
cache_sweeper_interval |
u64 | 300 | Interval in seconds between cache sweeper runs |
cache_sweeper_batch_size |
usize | 1000 | Maximum metadata entries inspected per sweep |
Cache Behavior
The cache follows standard HTTP cache headers from upstream servers.
Scope
The cache is shared across all clients and across inspected HTTP/1.1 and
HTTP/2 traffic. Responses are keyed by method and absolute URI. Vary headers
decide which request headers are part of the cache key. Enable caching only if
cross-client sharing is acceptable in your environment.
Supported Headers
- Cache-Control:
max-age,s-maxage,public,private,no-cache,no-store - Expires: HTTP date for cache expiration
- Date and Age: Used to account for a response's age before and while it is cached
- Vary: Cache keys include request headers specified by Vary
TTL Priority
Cache lifetime is chosen in this order:
s-maxage(shared cache max-age) - highest prioritymax-ageExpiresheaderforce_cache_durationfrom policy rule (fallback only)
The response's corrected age is subtracted from that lifetime, including when
force_cache_duration supplies the lifetime. Time spent receiving the response
body also consumes freshness. Cache hits replace any upstream Age field with
the response's current age.
What Gets Cached
- Methods: Only
GETandHEADrequests - Status codes: 200, 203, 204, 205, 301, 302
- Bypass: Requests with
AuthorizationorCookieheaders are never served from cache and are not stored. Requests with fixed-length or chunked bodies, or withRange, are also bypassed. - Not cached: Responses with
no-store,no-cache, orprivatedirectives, anySet-Cookieheader, or response trailers. HTTP/1 responses with transfer codings other than a solechunkedcoding are also forwarded without being stored.
Request Cache Directives
Request-side cache controls can force a bypass. If a request includes
Cache-Control: no-cache, Cache-Control: no-store, Cache-Control: max-age=0,
Pragma: no-cache, or Range, the cache will not be used and the response will
not be stored. ExfilGuard does not evaluate conditional validators locally;
requests with If-None-Match or If-Modified-Since are likewise forwarded
without cache lookup or storage. Otherwise, caching follows the upstream
response headers plus force_cache_duration from policy rules.
For fields named by Vary, ExfilGuard stores and compares the complete ordered
list of request field values. Requests with missing, extra, or reordered values
do not share a representation.
Eviction
The cache uses LRU eviction when capacity is reached. Expired entries are removed on lookup.
Layout and Sweeping
Cache entries live under a versioned subdirectory (v6 under the cache root).
Metadata is keyed by request URI and points to an immutable body generation.
When the layout changes, old version directories are deleted asynchronously. A
background sweeper runs every cache_sweeper_interval seconds and inspects up
to cache_sweeper_batch_size entries, removing expired entries and pruning
empty shard directories. It retains an in-memory offset so successive bounded
runs advance through the complete metadata set; restart resets the offset after
the startup scan has already examined every entry.
cache_total_capacity is an advisory bound for completed response bodies, not
a filesystem quota. In-progress cache fills share an additional
cache_max_entry_size staging allowance; when it is exhausted, those fills
skip caching without slowing or rejecting the forwarded responses. Operators
should therefore reserve at least the sum of both settings, plus space for
metadata and normal filesystem overhead. Partial staging files are removed as
soon as their fill is discarded.
Note
The cache does not support conditional revalidation (ETag/If-None-Match, Last-Modified/If-Modified-Since). Stale entries are discarded and fetched fresh from upstream.
Environment Variables
You can override any setting with environment variables. Use the
EXFILGUARD__ prefix and double underscores for nesting.
# Override listen address
EXFILGUARD__LISTEN="0.0.0.0:3128"
# Override log format
EXFILGUARD__LOG="text"
# Override timeouts
EXFILGUARD__CLIENT_KEEPALIVE_IDLE_TIMEOUT=60
EXFILGUARD__UPSTREAM_CONNECT_TIMEOUT=120
Complete Example
# Core settings
listen = "127.0.0.1:3128"
ca = { source = "builtin", dir = "./ca" }
clients = "clients.toml"
policies = "policies.toml"
clients_dir = "clients.d"
policies_dir = "policies.d"
# Logging
log = "text"
log_queries = false
# TLS
leaf_ttl = 86400
leaf_cache_capacity = 4096
leaf_mint_concurrency = 4
# Timeouts (seconds)
dns_resolve_timeout = 2
upstream_connect_timeout = 5
tls_handshake_timeout = 10
request_header_timeout = 10
request_body_idle_timeout = 30
response_header_timeout = 60
response_body_idle_timeout = 60
request_total_timeout = 0
client_keepalive_idle_timeout = 30
connect_tunnel_idle_timeout = 60
connect_tunnel_max_lifetime = 0
# Connection pool
upstream_pool_capacity = 32
# HTTP/2
http2_max_concurrent_streams = 100
# Size limits (bytes)
max_request_header_size = 32768
max_response_header_size = 32768
max_request_body_size = 0
# Cache (optional)
cache_dir = "./cache"
cache_max_entry_size = 104857600
cache_max_entries = 10000
cache_total_capacity = 1073741824
cache_sweeper_interval = 300
cache_sweeper_batch_size = 1000