Design Decisions
This file explains choices that may otherwise look like missing work.
Fixed HTTP/2 receive windows
ExfilGuard uses configurable HTTP/2 receive windows with per-stream and connection-wide limits. Larger windows improve throughput on high-bandwidth, high-latency links, but allow more data to accumulate when the receiving application is slow.
proxy::http2::server configures the windows for receiving client uploads.
proxy::http2::upstream configures them for receiving upstream responses,
including connections opened during CONNECT preflight.
In proxy::http2::forward, send_data_with_backpressure waits for capacity on
the outgoing stream. After forwarding a chunk, the caller releases the
corresponding capacity on the incoming stream. When the receiver stops reading,
this eventually stops the sender.
The windows have fixed sizes to keep configuration and flow-control behavior
simple. ExfilGuard does not estimate bandwidth and latency to adjust them
automatically. Operators can tune the sizes using the
configuration settings. The benchmarks in perf/h2/
measure throughput and memory use with slow readers.
Canonical request data for policy
ExfilGuard makes policy decisions from a normalized request view and keeps the original request target for forwarding.
Policy rules should not depend on small syntax differences. ExfilGuard also should not rewrite upstream-visible bytes in ways that change request meaning. It rejects ambiguous or conflicting syntax first, then canonicalizes requests that already have one clear meaning.
For policy matching, percent-encoded RFC-unreserved characters are decoded and the hex digits of retained escapes are uppercased. Dot-segment processing runs on that canonical policy path. IP-literal hosts in rules and requests are parsed and serialized to one numeric spelling before comparison. The original request target remains unchanged for forwarding, logging, cache keys, and signature-sensitive traffic.
Policy path patterns must already be canonical: configurations containing dot
segments or repeated slash separators are rejected. Rewriting a pattern could
silently change operator intent, especially when the removed segment contains
a wildcard. Patterns accept the same literal RFC path characters and retained
uppercase percent escapes as the canonical request view. Because * is the
path wildcard, \* is the policy-only spelling for a literal raw asterisk;
request paths themselves reject backslashes. This keeps raw *, encoded %2A,
and literal %2A text exactly distinguishable in policy.
Policy stops at the application-routing boundary
ExfilGuard evaluates the HTTP method and RFC-defined request target that it
receives on the wire. It does not emulate framework- or origin-specific routing
transformations. Valid reserved path characters remain part of the canonical
policy path, retained percent escapes remain encoded, and the original target
is forwarded unchanged. In particular, semicolon is valid path data, literal
; remains distinct from %3B, and %2F remains distinct from /.
Operators are responsible for making policy rules consistent with downstream routing semantics. Guessing application transformations here would reject standards-compliant traffic without providing a complete model of arbitrary origin rewrites.
Reject ambiguous syntax
ExfilGuard rejects malformed or ambiguous HTTP syntax on both sides of the proxy.
That includes cases such as:
- both
Content-LengthandTransfer-Encoding - malformed or ambiguously terminated chunk framing
- malformed CONNECT authorities
- raw or encoded backslashes
- encoded dot-segment tricks
- request targets containing URI-fragment delimiters
Upstream HTTP/1 status lines must use exact CRLF framing and the standard
HTTP/1.1 SP 3DIGIT SP [reason-phrase] grammar. Valid reason-phrase bytes are
preserved exactly; nonstandard separators and control bytes are rejected before
the response is committed downstream.
This is a security choice. ExfilGuard should not quietly “fix” a request into a different request or turn a malformed response into a different response.
HTTP/1 origins that send 204 No Content with one Content-Length field whose
decimal value is zero are a narrow compatibility exception. A 204 ends at the header
boundary regardless of framing fields, so ExfilGuard removes that redundant
header before forwarding or caching and sends no body. Nonzero or malformed
lengths and all Transfer-Encoding fields remain errors. The exception closes
both the upstream and downstream connections, sacrificing keep-alive for this
nonconforming case so unsolicited origin bytes cannot poison a pooled response.
Conforming 204 responses retain their existing connection behavior.
Close after a committed response fails
Once ExfilGuard has sent a final HTTP response head or 200 Connection
Established, a later forwarding failure closes the downstream connection and
is recorded in logs and metrics. It does not append another HTTP error
response.
After commitment, downstream bytes already have a defined protocol meaning; inside a CONNECT tunnel they may be arbitrary TLS or application data. A second plaintext status line would corrupt that stream and cannot reliably communicate the failure. Errors detected before commitment can still receive the appropriate HTTP error response.
Response-head limits include informational responses
For upstream HTTP/1, response_header_timeout is one absolute deadline from
the end of request transmission through the final response head.
max_response_header_size is one aggregate wire-byte budget across every
informational response and the final response head. Informational responses do
not receive fresh limits.
This bounds both slow header drips and fast informational-response floods using the existing operator controls. A separate informational-response count would add an arbitrary protocol limit without improving the byte or time bound.
Cache only bodyless requests
Shared-cache lookup and storage are limited to requests proven to have no body.
Body-bearing GET and HEAD requests are forwarded normally without using the
cache. Cache keys do not include bodies, and serving a hit before consuming a
body would leave request bytes in the downstream connection. Chunked requests
also bypass the cache because their emptiness is unknown until framing is
consumed.
Fields named by Vary are matched using their complete ordered list of parsed
field-value bytes. ExfilGuard does not generically comma-combine repeated fields
because whether combination is valid depends on the field grammar. This
conservative comparison can produce a cache miss for semantically equivalent
spellings, but it cannot reuse a representation selected using a missing,
extra, or reordered value. Cache layout changes invalidate entries that lack
this complete key.
Retry only replayable idempotent requests
UpstreamPool::take() polls the idle stream once before reusing it. EOF, read
errors, and unsolicited HTTP bytes cause the connection to be discarded. The
check adds no wait or request buffering. A peer can still close immediately
after the check, so forwarding failures remain subject to the retry rules below.
When reuse of a pooled HTTP/1 upstream connection fails with an error that
indicates a stale socket, ExfilGuard retries once on a fresh connection only if
the request is bodyless, no response has started downstream, and it uses a
standard idempotent method: GET, HEAD, OPTIONS, TRACE, PUT, or DELETE.
Response commitment is tracked before attempting the final head write, since
a failed write may already have delivered bytes. Underlying connection errors
cannot make a committed response retryable.
The bodyless restriction makes the request locally replayable without buffering. The method restriction follows HTTP idempotency semantics when delivery to the origin is ambiguous. Non-idempotent methods and extension methods whose semantics ExfilGuard cannot establish are not retried automatically.
Requests with service-supplied authentication headers are never retried, even when they are bodyless and idempotent. The service prepared those headers for one exact request. A retry would have to ask the service for new headers.
TLS leaf certificates are cached only in memory
ExfilGuard does not persist generated TLS leaf certificates or their private keys. Leaf minting is cheap enough that reminting after a restart is preferable to maintaining an on-disk private-key cache with its own capacity, expiry, and filesystem-safety lifecycle.
The in-memory cache is capacity-bounded. Concurrent requests for the same leaf are single-flighted, and globally limited mint jobs run on blocking workers so certificate generation cannot stall asynchronous I/O workers.
CA material is controlled by the process owner
ExfilGuard loads CA material only from a real owner-controlled directory. The directory and all CA files must belong to the process UID; private keys must be regular, non-symlink files with owner-only modes. Read-only owner modes are supported, but shared ownership and permissive modes are rejected at startup instead of being repaired automatically.
This intentionally excludes shared-volume and sidecar ownership models. A CA signing key can impersonate every inspected origin, so deployment convenience does not justify accepting material another local principal can replace or read.
CA lifecycle is selected explicitly
ExfilGuard has three CA sources with deliberately different ownership models.
builtin creates a long-lived root and intermediate once, persists only the
certificates and intermediate key through a recoverable filesystem transaction,
and does not renew them. files loads the same three artifacts but never creates
or changes them. vault generates each intermediate key in memory, has a
selected Vault PKI issuer sign it, and renews the complete issuer generation
before expiry.
The sources never silently fall back to one another. In particular, Vault unavailability after a restart prevents inspected HTTPS from starting because the previous in-memory issuer key is gone. During a running process, a failed renewal retains the current valid generation; after it expires, inspection fails closed instead of becoming a CONNECT tunnel. Replacing an issuer also replaces its leaf cache atomically so new handshakes cannot receive a leaf from the previous generation.
CA hierarchies must support arbitrary leaf names selected by policy. ExfilGuard
therefore rejects name constraints and any critical extension other than the
basicConstraints and keyUsage semantics it explicitly enforces, rather than
accepting an issuer whose additional constraints are ignored during leaf
minting.
ExfilGuard never loads or stores a root private key. File mode makes external ownership explicit, while Vault mode keeps CA and leaf private keys off disk. This separation is preferable to accepting partially managed directories or guessing an operator's intended lifecycle from whichever files happen to be present.
Vault connection, PKI trust, and login settings are global rather than owned by the inspection CA. The inspection CA, proxy listener, and authorization services use the same Vault connection and AppRole because they run inside the same ExfilGuard process; separate logins would not isolate them. Vault permissions still name separate signing paths for the inspection intermediate, proxy server certificate, and each authorization service's client certificate.
TLS on the proxy listener
proxy_tls enables TLS on the configured listener. Its server certificate
comes from files or a dedicated Vault signing role. Vault shares the existing
connection, authentication, and pinned roots, while the certificate names the
proxy itself. Vault private keys are generated locally and retained in memory.
The listener uses HTTP/1.1 for proxy requests and CONNECT. Inspected CONNECT sessions can negotiate HTTP/2 inside their own TLS connection. Keeping these transports explicit preserves the existing request and policy semantics. Password clients require TLS on the proxy listener so Basic credentials are encrypted from the first request. TLS remains optional for anonymous and lease-token configurations, allowing existing deployments to adopt HTTPS proxy connections independently of upgrading ExfilGuard.
Vault certificates renew halfway through their validity period. File certificates reload on SIGHUP independently of the policy snapshot. Valid replacements apply to new connections; active connections retain their certificate generation. Failed renewal or reload retains the current certificate, and its expiry stops new handshakes until a valid replacement is available.
HTTPS inspect and tunnel modes
In inspect mode, only a matching ALLOW rule may authorize the CONNECT host
and port preflight that lets ExfilGuard start a bumped TLS session. A matching
DENY rule does not authorize transport setup by itself, so deny-only
authorities fail at the outer CONNECT without opening an upstream connection.
Preflight does not allow arbitrary inner methods or paths. ExfilGuard checks
those only after decryption.
Inspected HTTP/1 accepts both origin-form targets and absolute HTTPS URIs. Every inspected HTTP/1 or HTTP/2 request must match the CONNECT host and effective port and retain the HTTPS scheme before policy evaluation or cache lookup. Host comparison uses the canonical destination identity, while forwarding preserves the original authority and path/query spelling; absolute HTTP/1 targets are sent upstream in origin-form. A conflicting Host header is rejected instead of silently changing its meaning. This supports signed requests from clients that send absolute-form inside TLS without allowing the session to change destinations or bypass inner method/path policy.
In tunnel mode, ExfilGuard may open a CONNECT tunnel and leave the payload
alone.
HTTP/1 clients may pipeline tunnel bytes immediately after the CONNECT header section. The HTTP parser can prefetch some of those bytes while finding the header boundary, so ExfilGuard preserves that bounded prefix across the mode switch. An allowed tunnel forwards it before later socket bytes; an inspected connection feeds it into the TLS handshake. A denied or failed CONNECT drops the prefix and never forwards it.
This split makes the operator’s intent clear. It also keeps logs and metrics tied to the decision that actually mattered.
Ordered policy evaluation
ExfilGuard evaluates policies in order. Inside each policy, it evaluates rules in order. The first match wins. If nothing matches, ExfilGuard denies the request.
This keeps policy behavior readable. Operators can put specific rules before general rules and know what will happen.
Credentials select a connection identity within source restrictions
The first request selects an anonymous, password, or lease-token client. Anonymous and lease-token clients are selected by source address within their own category. Basic authentication selects a password client by its unique name, then checks its source selector and password hash. Categories may share source ranges so node-level anonymous access and authenticated workloads can coexist. A workload can always omit credentials on a new connection to use the anonymous policy; operators must keep that baseline appropriately narrow.
Only the selected client's policies apply. Invalid credentials fail authentication. Authenticated policy denials never fall back to an anonymous policy. One identity and authentication method remain bound to the connection, including inspected HTTP/2 streams, so connection limits and audit attribution remain stable across requests.
IPv4-mapped IPv6 peer addresses are treated as their underlying IPv4 address. Configuration must therefore use the ordinary IPv4 address or CIDR; mapped-only selectors are rejected rather than accepted as rules that can never match.
There is one selectorless fallback, considered only within its own category. Password clients require an explicit source selector. Anonymous selectors must not overlap each other, and lease-token selectors must not overlap each other; password clients may share ranges because their usernames distinguish them.
By default, it trusts the immediate peer address. If you enable PROXY protocol
and trust the sender, it may use the address from that header instead. It does
not use X-Forwarded-For or similar HTTP headers for client identity.
Allowlisted PROXY peers are trusted infrastructure and must send their complete header promptly as the first bytes on the backend connection. A timeout and a separate pending-connection limit bound resource retention if that infrastructure stalls or malfunctions before ExfilGuard learns the logical client identity.
Password verification
Client configuration stores self-describing Argon2id, bcrypt, or SHA512-crypt hashes. Argon2id is the generation default; the other formats support existing configuration management tooling. Parsing enforces verification cost ceilings before publishing a configuration. Passwords require proxy TLS; adding a password client to a plaintext listener fails validation.
authentication::session::PasswordVerifier bounds pending connections and
blocking hash verification separately. A canceled verification retains its
worker permit until the blocking job finishes. Unknown usernames and source
mismatches perform a dummy verification. Repeated credentials on a bound
connection use a keyed fingerprint, avoiding repeated expensive hash work.
Owned password buffers are zeroized, and password hashes have redacted Debug
output.
Delegated authorization
A client that uses an authorization service sends its token with proxy authentication. ExfilGuard keeps the token with the connection. Policy cache entries are separated by service, token hash, source address, and audience. A malformed or changed token denies the request. So does an inactive or expired service policy. Requests without credentials use anonymous selection instead.
For inspected HTTPS, the token from CONNECT also applies to the decrypted
requests. ExfilGuard checks the current service policy for each one. It denies
raw CONNECT tunnels because it cannot see or check the requests inside them.
Both the local policy and the service policy must allow every request. The
service cannot change local settings. If it asks ExfilGuard to use a credential,
the reference, destination, headers, and body access must fit a local
credential_limit on the selected client.
Before asking for authentication headers, ExfilGuard resolves and checks the
destination and builds the exact request it will send. The service may fill only
the header positions approved by the client configuration. A unique ID and a
hash of the request tie the reply to that request. ExfilGuard sends nothing to
the API until this succeeds. A body may be buffered and dechunked only when
allowed; trailers and Expect: 100-continue are rejected.
HTTP/1 connections are pooled separately for each token. Connections used for authenticated requests are not returned to the pool. Within one inspected HTTP/2 session, authenticated and unauthenticated streams for the same token may share one API connection. Service-supplied headers are marked sensitive so HTTP/2 header compression does not store their values. Credentials that authenticate an entire connection rather than one request are not supported.
Clients that use delegated authorization bypass the shared response cache. Requests with service-supplied authentication are also never retried.
An authorization service may use a client certificate loaded from files or a client certificate signed by Vault. For Vault, ExfilGuard creates the private key and CSR in memory, verifies the returned client-only certificate against the globally pinned PKI roots, and renews it halfway through its actual lifetime. It builds the replacement HTTPS client before switching new service requests to it. Requests already in flight may finish with the previous certificate, but no new service request starts with an expired one. The inspection intermediate is never reused as a client identity.
Block non-public upstreams by default
By default, ExfilGuard blocks upstream addresses that are not globally routable on the public Internet.
This reduces SSRF risk. If a client is compromised or misconfigured, ExfilGuard should not let it reach internal or special-purpose network space unless an operator makes an explicit exception.
allow_private_upstream_cidrs provides global, restart-required exceptions.
ConfiguredUpstreamResolver applies them to literal addresses and individual
DNS results before connection attempts. Request policies still determine access.
Keeping address exceptions global lets inspected HTTPS validate its destination
during transport preflight, before the inner request is available for policy
evaluation.
One policy path for all front-ends
HTTP/1, HTTP/2, and CONNECT all go through the same policy code.
This keeps policy meaning consistent across front-ends. A request should not get a different result just because it came in through a different HTTP stack.
Explicit proxy traffic
Clients are expected to know they are talking to a proxy.
This keeps request meaning, client intent, and logging straightforward. Transparent proxying may come later.
Availability isolation combines client budgets with deployment controls
ExfilGuard assumes its proxy and metrics listeners are reachable only by the authorized clients and monitoring systems an operator intends to serve. It is not a complete denial-of-service isolation or multi-tenant fairness boundary, but an authorized or compromised client must not retain unbounded downstream connections.
Each configured client therefore has a default budget of 1,024 simultaneous downstream TCP connections, with an explicit per-client override. The count includes ordinary HTTP keep-alive connections, raw CONNECT tunnels, and inspected CONNECT sessions. A CIDR identity deliberately shares one budget across all matching machines. Admission follows trusted PROXY protocol processing and client authentication. Excess connections are closed or receive 503 without evicting established connections. Allowlisted PROXY peers and connections awaiting authentication have separate configurable pending budgets.
A connection retains its selected client's permit across reloads; changing a limit affects new admissions. Removing the client, changing its authentication mode or password hash, or making its source selector ineligible closes the connection, including idle connections and opaque tunnels. Policy-only changes apply to subsequent requests; established opaque tunnels retain their setup decision while the identity remains valid.
There is no separate global lifetime, per-IP, or per-rule ceiling. Client limits remain stable across policy reloads and are observable through bounded per-client metrics. Deployments that need broader availability isolation should still use firewall, load-balancer, or equivalent network-edge controls. A future adaptive overload controller would require real resource-pressure signals and hysteresis; it is separate from deterministic client budgets.
Request body limits are opt-in
By default, ExfilGuard does not cap inspected request body size.
It streams request bodies incrementally and relies on backpressure plus timeout
controls for transport safety. Body size alone does not make a request invalid
for policy purposes. Operators can still opt into a global request-body cap
with max_request_body_size when that tradeoff fits their environment.
When that cap is enabled, a request whose declared Content-Length already
exceeds it is rejected before ExfilGuard opens an upstream request. Bodies
without a declared length are still streamed and enforced incrementally.
Real trust store for outbound TLS
If the system trust store is empty, startup fails.
That rule is strict on purpose. If a host has no trust anchors, the fix is to install them.
Vault HTTPS also uses platform trust, with optional additional roots from
vault.tls_ca_cert, rather than a bundled public CA list. This lets operators
manage Vault transport trust through the host. Authorization-service HTTPS
remains restricted to its configured server_ca_cert roots; platform trust
must not broaden that service-specific boundary. Neither transport trust store
replaces the pinned roots used to validate certificates issued by Vault PKI.
Debian package provisioning is root-aware and declarative
The Debian package provisions the exfilguard system account with
systemd-sysusers and its owned directories with systemd-tmpfiles. Both tools
operate against DPKG_ROOT when dpkg explicitly runs maintainer scripts without
a chroot, so image construction cannot mutate the build host.
Purge removes ExfilGuard's state directory but retains the system account. This avoids reassigning its numeric UID to unrelated files that might remain outside the package-owned state tree.
Caching is opt-in
Response caching works only when global cache storage is configured and the allow rule enables caching.
ExfilGuard does not cache allowed traffic by default. That keeps response storage and freshness changes under explicit operator control.
Cache freshness does not imply mutation coherence
ExfilGuard makes no attempt to invalidate cached responses when resources are modified, even when it observes a successful POST, PUT, or DELETE. When a rule enables caching and upstream permits it, responses may be reused for their declared freshness lifetime. The existing per-rule fallback lifetime applies only when upstream freshness metadata is absent.
Multi-node deployments have independent caches, and mutations can also bypass ExfilGuard entirely. Local invalidation would not provide deployment-wide consistency, so ExfilGuard deliberately does not implement the mutation-driven invalidation required by RFC 9111 section 4.4. It offers freshness-based caching, not cache coherence. Operators select cacheable traffic through fine-grained allow rules and leave caching disabled where this freshness model is unsuitable. Expiration, capacity eviction, and corrupt-entry cleanup remain unchanged.
Response cache entries are protocol-neutral
Inspected HTTP/1.1 and HTTP/2 traffic uses one shared response cache. Entries store canonical response headers and decoded payload bytes, not HTTP/1 chunk framing or HTTP/2 frames. A hit is framed for the downstream protocol.
This keeps cache policy independent of ALPN and allows an entry populated over
one HTTP version to serve the other. Responses with trailers are forwarded but
not stored, and HTTP/1 responses with transfer-coding chains other than a sole
chunked coding are not representable and therefore skip storage.
Cache disk limits do not throttle forwarding
cache_total_capacity bounds completed response bodies, while all in-progress
cache fills share one additional cache_max_entry_size staging allowance.
These are advisory cache-storage bounds rather than a filesystem quota; normal
metadata and filesystem overhead still require headroom.
When the staging allowance is exhausted, ExfilGuard abandons only the cache copy and continues forwarding the response. It does not slow or reject client traffic to protect cache population. HTTP/1 and HTTP/2 waits for cache file operations on the response path are bounded by the response-body idle timeout; a storage error or timeout does not fail the forwarded response. File creation and final publication retain ownership after the caller stops waiting: they finish or clean up in the background, retaining staging reservations while publication runs. Publication acquires the shared cache lock within the caller's timeout before becoming independent of the request. Timed-out waiters discard their fills; only one publication per cache can outlive its caller, without an unbounded background queue. That publication may still store an entry after the timeout. Client writes and cache payload copies have independent deadlines; no cache operation runs inside a client-write idle timeout. Payload copies are sequential and bounded rather than queued in a separate background buffering pipeline. Same-key fills are not coalesced: that is an upstream-load optimization with response-variant complexity, not necessary for bounding temporary disk use.
SIGHUP reloads policy data and proxy certificate files
SIGHUP reloads clients, policies, and configured proxy server certificate
files. Listener addresses, certificate sources and paths, inspection CA material,
cache settings, metrics settings, and timeout settings require a restart.
The configured client and policy files are one operator-managed generation.
Deployment tooling must finish replacing the complete generation before it
sends SIGHUP, and must leave those files unchanged while ExfilGuard reads
them. ExfilGuard does not attempt to infer filesystem transactions, coordinate
independent file updates, or defend against signals sent during an incomplete
deployment.
Each completed reload is still validated as a whole and published atomically in memory. A failed reload leaves the previous snapshot active. Filesystem I/O, TOML parsing, validation, and policy compilation run on Tokio's blocking pool so a legitimate reload does not stall proxy I/O or timers.
One semantic config validator
The loader reads files, parses TOML, and builds config structs. One validator decides whether the config is valid.
This keeps the rules the same across startup, reloads, and any code that builds config in memory. The validator runs on the full in-memory config.
Settings stays close to exfilguard.toml
Settings is a plain Rust model of exfilguard.toml.
This keeps tests simple. Test code can build settings directly without builders or helper layers. The config is still checked strictly. Deserialization checks basic structure and types. Validation checks cross-field rules. Startup checks settings again before we start serving traffic.
Startup layers the main file, lexically ordered config.d/*.toml fragments,
and environment overrides in that order. Fragments are partial global settings;
all relative paths retain the main file's directory as their base. Global
settings require a restart; SIGHUP rereads client and policy data and configured
proxy server certificate files.
Some protocol features stay out of scope
ExfilGuard does not currently support some protocol features, including HTTP/1.0 and upgrade-style flows such as WebSocket over HTTP/1.1.
Supporting protocol upgrades would require separate policy, connection-lifetime,
and forwarding behavior. proxy::http::dispatch::serve_http_loop rejects HTTP/1.1
requests containing an Upgrade header or an upgrade connection token with
501 and closes the client connection. This check runs before policy evaluation
or forwarding, after client authentication. Access logs identify the failure as
upgrade_unsupported. Established CONNECT tunnels remain opaque.
Inspected live forwarding also preserves the HTTP version across the client and upstream legs instead of translating between HTTP/1.1 and HTTP/2. The upstream ALPN preflight prevents ExfilGuard from advertising H2 unless the origin selected H2. Each live request uses the downstream-selected version upstream and fails rather than translating if that version is unavailable. This limits semantic changes and protocol-conversion attack surface. The opt-in response cache remains protocol-neutral because a cache hit has no live upstream leg to translate.
HTTP/2 uses request-idle connection timeouts
The downstream HTTP/2 connection preface must arrive within
request_header_timeout. After setup, client_keepalive_idle_timeout applies
only while there are no active request streams. A client must produce its next
complete request within that window; control traffic such as PING does not
keep a request-idle connection alive. Active streams continue to use their
phase-specific body, response, and optional total-request timeouts.
The h2 library does not expose when an individual HEADERS/CONTINUATION block
starts. ExfilGuard therefore bounds an incomplete header block on an otherwise
idle connection with the connection-idle timeout instead of maintaining a
second partial HTTP/2 frame parser solely to apply a shorter header deadline.
This keeps setup and idle retention bounded without imposing a lifetime on
legitimate active or multiplexed sessions.
After the origin connection closes, graceful downstream shutdown also has one
client_keepalive_idle_timeout interval to finish. On expiry, ExfilGuard drops
the connection and cancels remaining streams. An unanswered shutdown PING must
not retain the connection indefinitely.
HTTP/2 stream admission follows the origin's limit
Each upstream HTTP/2 connection keeps one authoritative request sender.
ExfilGuard waits for that sender to become ready and opens the stream while
holding the same admission lock, so concurrent downstream requests cannot
bypass the origin's advertised MAX_CONCURRENT_STREAMS state by using cloned
senders. Only readiness and stream creation are serialized; request bodies and
responses remain multiplexed independently after their streams are open.
Waiting for upstream stream capacity is part of the optional end-to-end
request_total_timeout. Without that timeout, downstream HTTP/2 stream
admission still bounds the number of waiting requests on the connection.
HTTP/2 request and response bodies remain full duplex
After opening an upstream HTTP/2 stream, ExfilGuard polls request upload and the origin response concurrently. An origin may send a final response before the request body is complete, so ExfilGuard forwards that response immediately and continues driving both directions while they remain active. If the response completes first, the unfinished upload is canceled on that stream; other streams and the shared HTTP/2 connection remain usable.
The response-header phase timeout starts after request upload completes, as it
does for ordinary request/response exchanges, but an early response is still
observed during upload. The optional total-request deadline continues to cover
both directions throughout. A standards-defined RST_STREAM(NO_ERROR) after a
complete early response terminates only the unused request side and does not
discard the response.
The total request timeout covers processing after client authentication
When enabled, request_total_timeout starts after client authentication and
receipt of a complete inner HTTP request head. Password verification is part of
connection setup, with separate concurrency and hash-cost limits. One
absolute request deadline covers policy evaluation, authorization-service calls,
cache work, upstream resolution and connection setup, retries, request upload,
and complete response delivery. Phase-specific timeouts remain nested within
that budget.
If the deadline expires before a final response starts, ExfilGuard returns a 504. Once a final response has started, it closes or resets the request rather than writing a second response. Outer CONNECT setup and tunnel lifetime have separate controls, and cleanup after response delivery does not consume the request budget.
Upstream TCP connection setup has one multi-address budget
upstream_connect_timeout sets one deadline for connecting across all resolved
addresses. ExfilGuard preserves resolver preference within each address family
and alternates IPv6 and IPv4 candidates. While an attempt is pending,
proxy::upstream::race_connection_attempts starts further attempts 250 milliseconds
apart. If a failure leaves no attempt pending, it tries the next address
immediately. The first success cancels the remaining attempts.
All resolved candidates remain eligible until the deadline. DNS resolution and TLS handshakes have separate timeouts.
Raw CONNECT tunnel idleness is bidirectional
A raw CONNECT tunnel is idle only when no bytes have been successfully relayed
in either direction for connect_tunnel_idle_timeout. Continuous upload or
download traffic therefore keeps the tunnel active even when the reverse
direction is silent. Successful half-close propagation refreshes the shared
activity deadline so the surviving direction receives a full idle interval.
Individual tunnel writes remain bounded by the same timeout, preventing a peer
that stops reading from blocking one relay half indefinitely. The optional
connect_tunnel_max_lifetime remains an independent absolute lifetime limit,
including for continuously active tunnels.