haptic-annotations library¶
The haptic-annotations library is HAPTIC's native annotation vocabulary, under the haproxy-haptic.org/* prefix. Its capabilities are a best-of-breed superset of the three vendor annotation libraries (haproxytech, haproxy-ingress, nginx-ingress) combined: for every capability it adopts whichever vendor's semantics is strongest and exposes it under one clean name.
Where the vendor libraries exist to ease migration from an upstream ingress controller, this is the vocabulary to reach for when writing HAPTIC configuration from scratch. It's enabled by default.
Highlights it pulls together: haproxytech's pod-aware pod-maxconn and request capture; haproxy-ingress's agent checks, OAuth2-proxy flow, path-type control, and four config-section injection points; and nginx-ingress's canary routing, request mirroring, and bandwidth throttling — alongside the timeouts, load balancing, TLS, CORS, redirects, HSTS, session affinity, access control, and authentication all three share.
Overview¶
This library is enabled by default. See haproxy-haptic.org/* annotations render to HAProxy config live:
Coming from another ingress controller?
Keep your existing annotations working by enabling the matching vendor library instead — see Migrating to HAPTIC. Vendor and haproxy-haptic.org/* annotations coexist on the same Ingress as long as each feature is configured through a single family — see Don't mix families for one feature.
Configuration¶
Don't mix annotation families for one feature¶
You may combine haproxy-haptic.org/* and vendor annotations on the same Ingress, but each feature must come from a single family. Configuring one feature through two families — for example haproxy-haptic.org/waf-policy and haproxy-ingress.github.io/waf, or haproxy-haptic.org/cors-enable and nginx.ingress.kubernetes.io/enable-cors — is a conflict, even when the two values agree, because the result would otherwise depend on which library renders last.
HAPTIC handles the conflict in two ways, depending on when it's caught:
- When you apply or edit the Ingress, the admission webhook rejects the change with a message naming the feature, the families, and the colliding annotations. This stops new conflicts from ever reaching the cluster.
- For an Ingress that already carries a conflict (applied before the check existed, or through a bypassed webhook), the controller keeps serving traffic and records a
WarningEvent with reasonAnnotationFamilyConflicton the Ingress instead of failing — one bad Ingress must not block config updates for the whole fleet. Find it withkubectl describe ingress <name>orkubectl get events --field-selector reason=AnnotationFamilyConflict, then remove the duplicate annotation.
Different features from different families are fine (WAF from one family, CORS from another), and so are genuinely different parameters of the same category — for example a connect timeout from one family and a server timeout from another. Only enabled families count: a vendor annotation whose library is disabled is inert and never collides.
How HAPTIC handles a misconfigured annotation¶
The same two-stage handling applies to any invalid annotation value (a bad redirect code, a malformed rewrite, an out-of-range port), not just family conflicts. It also applies to every annotation library, not only this one.
- When you apply or edit the Ingress, the admission webhook rejects the change and names the offending annotation, so a typo never reaches the cluster.
- For an Ingress that's already in the cluster (applied before a check existed, or through a bypassed webhook), the behavior depends on the kind of feature:
- Routing and presentation features (redirects, CORS, cookie/header/location rewrites, canary, compression, traffic mirroring, host rewrites, fixed/mock responses) — the controller records a
WarningEvent on the Ingress, skips that one feature for that one Ingress, and keeps serving the rest of the fleet. One bad Ingress can't block config updates for everyone. Find these withkubectl get events --field-selector reason=InvalidAnnotationValue(orreason=InvalidAnnotationfor malformed values), orkubectl describe ingress <name>. - Security features (authentication, client-certificate/mTLS, WAF, rate limiting, request-body validation) — the render still hard-fails. HAPTIC never silently disables a security control, because a skipped auth or WAF check would let traffic through unprotected (fail-open). Fix the annotation to restore reconciliation.
- Routing and presentation features (redirects, CORS, cookie/header/location rewrites, canary, compression, traffic mirroring, host rewrites, fixed/mock responses) — the controller records a
The reason strings on the Events are stable and machine-readable, so you can alert on them.
Annotation reference¶
Every annotation below works, except one marked ❌ Removed. Most are ✅ Supported; a few are marked ⚠️ Caveat — they work too, but with the behavioural limitation described alongside. Nothing is silently ignored: a removed annotation emits a Warning Event, it's never dropped quietly.
Path and host matching¶
Route which requests reach a backend and alias extra hostnames onto an existing host.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/path-type |
✅ Supported | Overrides how the path matches when the Ingress pathType is ImplementationSpecific: regex, exact, prefix (trailing slash normalized), or begin. |
haproxy-haptic.org/host-alias |
✅ Supported | Adds extra exact hostnames (comma- or space-separated) that route to the same backends as the Ingress's primary host. Each hostname becomes a host-map entry pointing at the primary host's normalized routing key, so no backends or path-map entries are duplicated. Each hostname is injection-guarded (control characters and spaces rejected). |
haproxy-haptic.org/host-alias-regex |
✅ Supported | Adds a regular-expression hostname pattern that routes every matching hostname to the same backends as the Ingress's primary host. The pattern becomes a regex host-map entry pointing at the primary host's normalized routing key, consulted after an exact host-map miss. The pattern is injection-guarded (control characters and spaces rejected). |
Backend tuning¶
Per-backend timeouts, load balancing, connection limits, health/agent checks, and a raw-directive escape hatch.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/agent-check-addr |
✅ Supported | Sets the agent-check address via agent-addr; requires agent-check-port. |
haproxy-haptic.org/agent-check-interval |
✅ Supported | Sets the agent-check interval via agent-inter; requires agent-check-port. |
haproxy-haptic.org/agent-check-port |
✅ Supported | Enables the agent check on the given port (1-65535) via agent-check and agent-port; required by the other agent-check-* keys. |
haproxy-haptic.org/agent-check-send |
✅ Supported | Sets the string sent to the agent check via agent-send; requires agent-check-port. |
haproxy-haptic.org/check |
✅ Supported | Toggles server health checks; off emits no-check so servers aren't health-checked. |
haproxy-haptic.org/config-backend |
✅ Supported | Injects raw, operator-authored HAProxy directives verbatim into the backend section. Intended for trusted configuration, not request data. |
haproxy-haptic.org/fullconn |
✅ Supported | Emits fullconn <n> on the backend. HAProxy uses this threshold to scale each server's minconn/maxconn range as backend load rises. For a hard per-server cap, use maxconn-server. |
haproxy-haptic.org/health-check-fall |
✅ Supported | Sets the failed-check count before a server is marked down via fall. |
haproxy-haptic.org/health-check-interval |
✅ Supported | Sets the health-check interval via inter; ignored when check is off. |
haproxy-haptic.org/health-check-port |
✅ Supported | Sets the health-check port (1-65535) via port. |
haproxy-haptic.org/health-check-rise |
✅ Supported | Sets the successful-check count before a server is marked up via rise. |
haproxy-haptic.org/health-check-uri |
✅ Supported | Enables HTTP health checks via option httpchk; a bare path becomes GET <path>, and a value containing a space is used verbatim. |
haproxy-haptic.org/initial-weight |
✅ Supported | Sets the initial server weight (0-256) via weight. |
haproxy-haptic.org/load-balance |
✅ Supported | Sets the backend balance algorithm: roundrobin, static-rr, leastconn, first, source, random, or a parameterized uri, url_param(<name>), hdr(<name>), or rdp-cookie(<name>); an invalid value fails the render. |
haproxy-haptic.org/maxconn-server |
✅ Supported | Sets the per-server maximum concurrent connections via maxconn. |
haproxy-haptic.org/maxqueue-server |
✅ Supported | Sets the per-server maximum queued connections via maxqueue. |
haproxy-haptic.org/pod-maxconn |
✅ Supported | Sets a cluster-wide connection budget, divided across the ready HAProxy pods and rounded up to a power of two, then applied as each server's maxconn. |
haproxy-haptic.org/proxy-protocol |
✅ Supported | Sends the PROXY protocol header to servers: proxy/proxy-v1 emit send-proxy, and proxy-v2, proxy-v2-ssl, proxy-v2-ssl-cn emit the matching send-proxy-v2 variant; any other value fails the render. |
haproxy-haptic.org/scale-server-slots |
❌ Removed | No longer has any effect. Servers are named after their pods (ADR-0011) and scale with no reserved slots and no reload, so there is nothing to size. Setting it emits a Warning Event; remove the annotation. |
haproxy-haptic.org/timeout-check |
✅ Supported | Sets the check timeout via timeout check. |
haproxy-haptic.org/timeout-connect |
✅ Supported | Sets the connect timeout via timeout connect. |
haproxy-haptic.org/timeout-http-request |
✅ Supported | Sets the request timeout via timeout http-request. |
haproxy-haptic.org/timeout-keep-alive |
✅ Supported | Sets the keep-alive timeout via timeout http-keep-alive. |
haproxy-haptic.org/timeout-queue |
✅ Supported | Sets the queue timeout via timeout queue. |
haproxy-haptic.org/timeout-server |
✅ Supported | Sets the server timeout. Reload-free: the value moves into backend-timeouts.map (keyed on the backend), read by a uniform http-request set-timeout server line every backend carries. |
haproxy-haptic.org/timeout-tunnel |
✅ Supported | Sets the tunnel timeout. Reload-free: the value moves into backend-timeouts.map (keyed on the backend), read by a uniform http-request set-timeout tunnel line every backend carries. |
haproxy-haptic.org/consistent-hash-by |
✅ Supported | Configures consistent hashing on the backend, emitting a balance directive plus hash-type consistent. Accepts a hash key: uri, source, $http_<name>, $arg_<name>, or $cookie_<name>; any other value is used verbatim as a HAProxy fetch expression via balance hash <value>. |
Backend TLS (to the upstream)¶
Speak TLS to the backend Service — protocol, verification, client certs, SNI, ciphers.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/backend-ca-secret |
✅ Supported | Loads the Secret's ca.crt as the backend ca-file and requires TLS verification; a missing Secret or key is skipped with a warning comment. |
haproxy-haptic.org/backend-ciphers |
✅ Supported | Sets the cipher list for TLS 1.2 and earlier via ciphers on a TLS-enabled backend. |
haproxy-haptic.org/backend-ciphersuites |
✅ Supported | Sets the cipher suites for TLS 1.3 via ciphersuites on a TLS-enabled backend. |
haproxy-haptic.org/backend-crt-secret |
✅ Supported | Presents the Secret's tls.crt and tls.key as a client certificate to the upstream via crt; a missing Secret is skipped with a warning. |
haproxy-haptic.org/backend-protocol |
✅ Supported | Selects the upstream protocol from h1, h2, h1-ssl, h2-ssl, http, https, grpc, or grpcs; the h2, grpc, h2-ssl, and grpcs values add proto h2, and h1-ssl, https, h2-ssl, and grpcs speak TLS to the upstream. |
haproxy-haptic.org/backend-sni |
✅ Supported | Sets the SNI sent to the upstream: host or sni forwards the request Host via sni req.hdr(host), and any other value is sent literally via sni str(<value>). |
haproxy-haptic.org/backend-ssl-protocols |
⚠️ Caveat | Maps a space-separated TLS version list to ssl-min-ver (lowest) and ssl-max-ver (highest). HAProxy expresses only a contiguous span, so a gap in the list (for example, skipping TLSv1.2) can't be represented. |
haproxy-haptic.org/backend-verify |
✅ Supported | A truthy value (on, true, yes, 1) requires upstream certificate verification, and fails closed rather than silently downgrading to verify none when no CA is available. |
haproxy-haptic.org/backend-verify-host |
✅ Supported | Sets the expected upstream certificate hostname via verifyhost, independent of the SNI value. |
Rate and bandwidth limiting¶
Per-source request-rate caps (reload-surviving stick-tables), shared fleet-wide request budgets through the rate-limit SPOA plugin, and download/upload bandwidth throttling.
On a route with cache-enable: "true", every limiter is enforced on the client leg, before the request reaches the cache — so cache hits consume the budget, and a burst of cached responses is throttled the same as origin traffic. The per-pod caps and shared bandwidth scopes each use a per-route table proxy there, which also means a shared bandwidth scope and a per-source cap may coexist on a cached route. A consumer-keyed shared limit on a cached route requires api-key-secret (see Shared response cache).
Two facts about bandwidth limits surprise people, so check them against what you intend:
- The limit applies per stream, not per connection. An HTTP/2 or HTTP/3 client that opens ten streams gets ten times the configured rate. Use
bandwidth-limit-scope: clientwhen you want one budget per client regardless of how many streams it opens. - Only the HTTP payload is metered. Headers are never counted toward the limit.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/download-bandwidth-limit |
✅ Supported | Caps the bytes per second sent toward the client, using a bwlim-out filter plus http-request set-bandwidth-limit. Independent of the request-rate caps; both can apply to the same Ingress. Byte-size values are validated before interpolation. |
haproxy-haptic.org/upload-bandwidth-limit |
✅ Supported | Caps the bytes per second received from the client, using a bwlim-in filter. Can be combined with download-bandwidth-limit; each direction gets its own filter. Byte-size values are validated before interpolation. |
haproxy-haptic.org/bandwidth-limit-scope |
⚠️ Caveat | Who shares the budget: stream (default, each stream gets the full limit), client (all streams from one source IP share it, key src), or service (every stream of this backend shares it, key be_id). client and service add a stick-table to the backend, and HAProxy allows only one per backend — so they can't be combined with rate-limit-rps, rate-limit-rpm, or rate-limit-connections, and the render fails if you try. On a route with cache-enable: "true" the combination works, because each client-leg limiter gets its own table proxy. service scopes to one Ingress route to a service, not to a Kubernetes Service shared by several Ingresses. |
haproxy-haptic.org/rate-limit-algorithm |
✅ Supported | Shared limiter algorithm: token-bucket (default, low-latency lease mode) or gcra (exact mode, one synchronous store check per request). gcra is for low-volume contractual limits; use the default token-bucket mode for public-edge DoS protection. During a store failure, both modes follow rateLimit.shared.failClosed. Requires rate-limit-requests, rateLimit.shared.enabled=true, and an effective Redis/Valkey store endpoint. |
haproxy-haptic.org/rate-limit-burst |
✅ Supported | Shared limiter burst allowance; defaults to rate-limit-requests. Must be a positive integer. |
haproxy-haptic.org/rate-limit-connections |
✅ Supported | Caps concurrent connections per source IP; ignored when rate-limit-rps or rate-limit-rpm is set. |
haproxy-haptic.org/rate-limit-key |
✅ Supported | Shared limiter key dimension: ip (default) or consumer. Source-IP limits run in the frontend before Coraza and request-schema validation, making them the correct DoS guard. Consumer limits run in the selected backend after native API-key/JWT authentication has established the identity, falling back to source IP when no identity is present; use them for authenticated quotas, not as the sole public-edge flood control. On a route with cache-enable: "true" they run on the client leg instead and require api-key-secret — the only client-leg identity source; the render refuses the pairing without it. |
haproxy-haptic.org/rate-limit-period |
✅ Supported | Overrides the rate window. For the per-pod stick-table limiter, when unset the window derives from the active cap: 1 second for requests per second, 60 seconds for requests per minute, and a 30-second table TTL for connection caps. For the shared limiter it defaults to 1s and accepts ms/s/m/h/d; zero or malformed values fail the render. The shared rule's full refill horizon (burst × period / requests) must not exceed 3600 seconds, the bundled plugin's maximum safe state TTL. |
haproxy-haptic.org/rate-limit-requests |
✅ Supported | Enables one fleet-wide budget through the rate-limit SPOA plugin. It requires rateLimit.shared.enabled=true plus the chart-managed HA Valkey/Sentinel store or one bring-your-own HA endpoint; HAPTIC fails the render rather than silently using per-pod budgets during normal operation. On a Valkey failure, the default policy uses a bounded limiter in each sidecar. Each emergency bucket starts with its configured burst and refills at the configured rate; lease mode can also spend outstanding lease tokens. If local state or the hub/plugin can't answer, HAProxy allows the request. These paths set rate_limit_degraded; plugin metrics distinguish fallback allows and limits. Set rateLimit.shared.failClosed=true to deny instead. Source-IP rules execute before Coraza to keep rejected floods from consuming WAF CPU. The managed store is a fixed-size HA topology with Sentinel failover, a PodDisruptionBudget, NetworkPolicy, and noeviction; configure external stores without eviction. Multiple external URLs fail validation because the bundled plugin shares one circuit breaker across its shards. |
haproxy-haptic.org/rate-limit-rpm |
✅ Supported | Caps requests per minute per source IP (a 60-second http_req_rate window); ignored when rate-limit-rps is also set. |
haproxy-haptic.org/rate-limit-rps |
✅ Supported | Caps requests per second per source IP via an http_req_rate stick-table; requests over the cap are rejected with the deny status (default 429), with no burst allowance. |
haproxy-haptic.org/rate-limit-size |
✅ Supported | Sets the stick-table size (default 100k). |
haproxy-haptic.org/rate-limit-status-code |
✅ Supported | Sets the HTTP status returned to rejected requests (default 429). Validated as a 3-digit HTTP status before interpolation, then emitted as the http-request deny deny_status code. |
haproxy-haptic.org/rate-limit-allowlist |
✅ Supported | Exempts comma-separated CIDRs from the rate limit; invalid CIDRs fail the render. |
Compression¶
HAProxy-side response compression, per Ingress. On by default: a bundled
governance rule sets compress-enable on any Ingress that doesn't set it
itself, so you get compression without annotating anything, and an Ingress that
does set the annotation always keeps its own value.
HAProxy compresses only responses the backend left uncompressed. It skips a
response that already carries Content-Encoding, one whose Cache-Control says
no-transform, a multipart/* body, a status other than 200/201/202/203, and
anything the client didn't advertise support for in Accept-Encoding. It adds
Vary: Accept-Encoding itself, so a shared cache in front stays correct.
Compression runs before the bandwidth limiter, so a download-bandwidth-limit on a compressed route meters the compressed bytes that go on the wire, not the larger uncompressed response.
Compressing HTTPS responses re-opens BREACH
Compression is on by default, so read this before assuming it's safe for every route you serve.
BREACH recovers a secret from an HTTPS response by watching how its compressed length changes. It needs one page to do three things at once: be served over TLS, contain a secret (a CSRF token, a session identifier, an API key), and reflect attacker-controlled input into the same response body. Given that, an attacker who can make the victim's browser issue requests reads the secret out byte by byte, without breaking TLS.
Compression is what makes the length vary, so turning it on is what exposes the page. Most routes don't meet all three conditions — a JSON API that reflects nothing, static assets, and anything unauthenticated are unaffected — which is why on-by-default is the same choice nginx-ingress, Cloudflare and most CDNs make.
For a route that does meet them, opt out:
Narrowing compress-types to exclude text/html also helps if the reflected
secret only ever appears in HTML. The durable fixes are application-side:
per-request CSRF tokens, or masking the token so its compressed length
doesn't correlate with its value.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/compress-algorithm |
✅ Supported | Compression algorithm (default gzip; deflate/raw-deflate). brotli/zstd fail the render — unavailable in the community HAProxy build. |
haproxy-haptic.org/compress-enable |
✅ Supported | true compresses this backend's responses, false turns it off for this Ingress. Injected as true when unset — set it to false to opt out. |
haproxy-haptic.org/compress-types |
✅ Supported | Comma-separated MIME types to compress (default a standard text/JSON/XML/SVG set). |
To turn compression off for the whole fleet rather than one Ingress, disable the rule that injects it:
controller:
config:
templatingSettings:
extraContext:
governance:
rules:
haptic-compress-enable:
enabled: false
CPU cost and the two global limits that bound it are covered in Response compression.
Shared response cache¶
Routes cache-eligible GET and HEAD requests through a chart-deployed, consistent-hash-sharded Varnish tier, so the cache is shared across the whole HAProxy fleet. Other methods go directly to the application. If every Varnish shard is unhealthy, HAProxy uses the application backend and records cache_degraded. A local dispatch stage also retries a failed cache attempt directly, including when another shard still looks healthy. HTTP health checks verify Varnish's path back to the internal origin before a shard receives traffic. cache.haproxy.responseTimeoutMs is the inactivity timeout on the Varnish hop; a cache miss spends it while waiting for the application's response headers. Before headers reach the client, a timeout or transport failure retries the GET or HEAD directly. After response delivery starts, HAProxy can't safely replay it; an idle partial response is terminated instead. Set the value above your application's normal time to first byte to avoid duplicate fetches. These annotations take effect only when the tier is enabled (cache.varnish.enabled). The tier's default-on NetworkPolicy admits cache requests only from the same release's HAProxy pods and limits Varnish egress to DNS plus the same HAProxy HTTP origin; disable cache.varnish.networkPolicy.enabled only when replacing it with equivalent isolation. Per-route behaviour is driven by internal X-Haptic-Cache-* headers that HAProxy strips from the client request before both cached and direct paths, so a client can't influence the cache key, origin routing, or exclusion rules. Cache-miss fetches enter HAProxy through a dedicated backend-fetch frontend that runs no limiter, so one external request consumes one budget — no double counting, no cache-cold self-throttling.
Caching authenticated content requires consumer specifically. A request carrying Authorization or a Cookie is normally never served from cache, and HAPTIC only overrides that when the key is consumer, whose value is the authenticated identity — so one caller can never be served a response belonging to someone else. api-key-secret, hmac-secret and consumer-groups-secret are each enforced on the client leg for a cache-enabled route, before the cache is consulted — the application backend that normally holds those checks is never entered once the route switches to the cache. Combining any of them with cache-enable is therefore supported; a caller presenting no credential is denied rather than served the cached authenticated response. One pairing is refused: consumer-groups-secret with cache-enable also needs api-key-secret, because the group check reads the consumer identity and only api-key resolves it on the client leg — with JWT identity it's resolved in the backend a cached route never enters, and taking sub from the unverified token instead would authorize on a forged claim. Keying on header:, cookie:, query: or src doesn't lift the restriction: those may or may not correlate with the caller, so authenticated requests on such a route go to the origin every time while unauthenticated ones still cache under the key you chose.
Rate and bandwidth limits follow the same client-leg rule (see Rate and bandwidth limiting): the per-pod caps, bandwidth throttles, and the shared limiter all run before the cache is consulted, so a cache hit counts against the budget and is throttled like any other response. One pairing mirrors the consumer-groups refusal: rate-limit-key: consumer with cache-enable needs api-key-secret, because only api-key resolves the consumer identity on the client leg.
A cache-key also changes what HAPTIC tells caches downstream of it — a content delivery network or proxy in front of the cluster. Components the client itself sends are declared as Vary, so a downstream cache keys on the same dimension HAPTIC does: header:<h> becomes Vary: <h>, and cookie:<c> becomes Vary: Cookie. A query:<q> component needs nothing, because the query string is already part of the URL every cache keys on.
This downstream cache contract applies to every response from the cache-enabled route, including methods and paths that bypass Varnish. A proxy in front of HAPTIC can still cache those responses, so limiting the contract to Varnish traffic would let it collapse distinct callers or variants during a direct fallback.
consumer and src can't be declared this way, because a downstream cache never receives the value HAPTIC keyed on — declaring it would make every caller look identical and collapse them onto one shared response. Routes keyed on either are marked Cache-Control: private instead, which keeps shared caches out while still letting a browser cache its own copy. Vary is added to whatever the origin already sent, and an origin's own Vary is honoured as usual — every producer in the chart appends rather than replaces, including the CORS rule. A public directive on an identity-keyed route is rewritten to private rather than left beside it, since a response carrying both leaves each cache to decide which wins.
The cache is memory-only and doesn't survive a restart. Of the storage engines this build ships, malloc holds objects in memory and file maps an unlinked file, so neither outlives the process, and the persistent engines aren't compiled in. A pod restart therefore empties that pod's share of the cache. The tier runs as a StatefulSet, whose rolling update replaces one pod at a time, so an upgrade leaves the other shards warm and only the keys hashed to the restarting pod miss. Raise cache.varnish.replicas to shrink the share any one restart affects.
The two staleness annotations are independent, and setting one doesn't imply the other. cache-stale-while-revalidate trades freshness for latency: within its window nobody waits for the origin. cache-stale-if-error trades nothing until something breaks: an ordinary expiry still fetches and waits, and the stale copy is reached only when that fetch fails. Set both when you want fast expiry and an outage cushion — for example cache-stale-while-revalidate: "30" with cache-stale-if-error: "600".
Every cached response carries an X-Cache header: HIT when it was served fresh from cache, MISS when it came from the origin, and STALE when it was served past its lifetime under either annotation. The access log records the same value in its cache field, so you can tell a route that's serving stale from one that's genuinely fresh. Two more cache fields ride along: cache_age, how many seconds old the served object was, and cache_uncacheable_reason, which says why a response wasn't stored (content_type_excluded, too_large, set_cookie, status_not_cacheable, origin_refused_sharing) — otherwise a route that silently never caches looks identical to one that's always missing.
The Vector sidecar projects these into Prometheus counters on the metrics port it already serves — haptic_cache_status_total{status}, haptic_cache_age_seconds_total, haptic_cache_uncacheable_total{reason}, and haptic_degraded_cache_total — because Varnish serves no metrics endpoint of its own. They're declared in vector.logMetrics, so you can switch one off or add your own from any log field. Counters are tagged by status or reason only; use the log when you need it per route.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/cache-enable |
✅ Supported | The value true routes GET/HEAD requests through healthy Varnish shards. Other methods and all-shards-unhealthy periods use the application backend directly. |
haproxy-haptic.org/cache-exclude-content-types |
✅ Supported | Comma-separated response media types never cached even if otherwise eligible (for example text/html); matched after stripping the ; charset=… suffix. |
haproxy-haptic.org/cache-exclude-paths |
✅ Supported | Comma-separated request path prefixes that bypass the cache and go straight to the app. |
haproxy-haptic.org/cache-key |
✅ Supported | Adds a vary component to the cache key: consumer, src, header:<h>, cookie:<c>, query:<q>, or a comma-separated composite. Only consumer lets a route cache responses to authenticated requests, because only it identifies the caller; see below. The same variance is declared to caches downstream of HAPTIC. |
haproxy-haptic.org/cache-negative-ttl |
✅ Supported | Seconds to cache a 404 or 410, so repeated requests for something that isn't there stop reaching the origin. Independent of cache-ttl, and on its own clock: a cached 404 during a bad deploy keeps being served for its lifetime, so keep it short. Only 404 and 410 — an error response is never cached. Without this annotation, a route that sets cache-ttl never caches a 404, while one that leaves lifetimes to the origin inherits the cache's own default of two minutes. |
haproxy-haptic.org/cache-max-object-size |
✅ Supported | Maximum cacheable response size in bytes; a larger response (by Content-Length) stays uncacheable. |
haproxy-haptic.org/cache-revalidate |
✅ Supported | Seconds past expiry the object is kept so the refresh can be a conditional request the origin answers with 304 Not Modified instead of a full body. Costs cache memory; defaults to 0, which keeps nothing. |
haproxy-haptic.org/cache-stale-if-error |
✅ Supported | Seconds past expiry a stale response may still be served, but only when the refresh fails. On its own it doesn't change what an ordinary expiry does: that still fetches from the origin and waits. An origin error never replaces a good cached response. |
haproxy-haptic.org/cache-stale-while-revalidate |
✅ Supported | Seconds past expiry a stale response is served immediately while the cache refreshes it in the background. Without it, a route gets the cache's 10-second default. |
haproxy-haptic.org/cache-strip-set-cookie |
✅ Supported | The value true drops Set-Cookie from the response before the cache decides whether it can be stored, so a public asset behind an analytics cookie stays cacheable. Never set it where Set-Cookie carries a session. |
haproxy-haptic.org/cache-ttl |
✅ Supported | Cache lifetime in seconds for the route. A response stays uncacheable when it's non-2xx, carries Set-Cookie, or declares Cache-Control: no-cache/no-store/private or Vary: * — an origin's refusal to be shared is always honoured. The value auto follows the origin's Cache-Control and Expires instead of forcing a fixed lifetime. |
Rewriting, retries, and session affinity¶
Path/target rewriting, body-size limits, upstream retries, Host/header overrides, and cookie-based stickiness.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/affinity |
✅ Supported | The value cookie enables cookie-based session affinity via the backend cookie directive. |
haproxy-haptic.org/backend-connection-header |
✅ Supported | Overrides the Connection header sent to the backend server. |
haproxy-haptic.org/path-rewrite |
✅ Supported | Rewrites the request path via http-request replace-path: a <from> <to> pair rewrites the match, and a bare value replaces the whole path. |
haproxy-haptic.org/max-request-body-size |
✅ Supported | Limits the request body size (accepts k, m, or g suffixes), returning 413 when exceeded; 0 means unlimited. |
haproxy-haptic.org/request-buffering |
✅ Supported | on or off, overriding the fleet-wide requestBuffering.enabled default for this route. See Request buffering. |
haproxy-haptic.org/retry-on |
✅ Supported | Sets the conditions under which HAProxy retries a failed request against the next server, emitting retry-on. Conditions cover connection failures, response timeouts, malformed responses, and per-status-code retries (http_<code>); a disable value emits retries 0. option redispatch in defaults sends the retry to a different server. |
haproxy-haptic.org/retries |
✅ Supported | Sets the number of retry attempts against backend servers via HAProxy retries; 0 keeps the default. |
haproxy-haptic.org/session-cookie-domain |
✅ Supported | Sets the session cookie's Domain via the domain cookie keyword. |
haproxy-haptic.org/session-cookie-dynamic |
✅ Supported | Enables dynamically generated cookie values via the dynamic cookie keyword (default on). |
haproxy-haptic.org/session-cookie-keywords |
✅ Supported | Appends extra keywords to the cookie directive verbatim (for example, httponly). |
haproxy-haptic.org/session-cookie-max-age |
✅ Supported | Sets the browser cookie lifetime in seconds via attr Max-Age; this is the cookie's Max-Age, not HAProxy's server-affinity lifetime. |
haproxy-haptic.org/session-cookie-name |
✅ Supported | Sets the session cookie name (default INGRESSCOOKIE). |
haproxy-haptic.org/session-cookie-path |
✅ Supported | Sets the session cookie's Path via attr Path. |
haproxy-haptic.org/session-cookie-preserve |
✅ Supported | The value true adds the preserve keyword to the cookie directive. |
haproxy-haptic.org/session-cookie-samesite |
✅ Supported | Sets the cookie SameSite attribute (None, Lax, or Strict) via attr SameSite. |
haproxy-haptic.org/session-cookie-secure |
✅ Supported | The value true sets the cookie Secure attribute via attr Secure. |
haproxy-haptic.org/session-cookie-strategy |
✅ Supported | Selects the cookie mode: insert (default), rewrite, or prefix; insert and prefix add indirect nocache. |
haproxy-haptic.org/set-host |
✅ Supported | Overrides the Host header sent to the upstream. |
haproxy-haptic.org/x-forwarded-prefix |
✅ Supported | Sets the X-Forwarded-Prefix header sent to the upstream. |
Request buffering¶
HAProxy buffers request bodies by default, so a client that trickles its upload holds an HAProxy buffer instead of a backend server slot. Set haproxy-haptic.org/request-buffering to change that for one route:
Use off when the route's clients declare a Content-Length but still expect a response before the request body ends, such as a resumable-upload endpoint. Use on to buffer one route while requestBuffering.enabled is false fleet-wide.
Only requests that declare a Content-Length are ever buffered, so on can't break a gRPC or chunked streaming route. The base library explains why that condition is the right one.
Headers, CORS, and access control¶
Request/response header manipulation, capture, CORS, source-IP allow/deny, and upstream cookie/redirect rewriting.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/allowlist-source-range |
✅ Supported | Allows only the listed CIDRs and denies all other source IPs for the host. |
haproxy-haptic.org/cors-allow-credentials |
✅ Supported | Sets Access-Control-Allow-Credentials: true when enabled. |
haproxy-haptic.org/cors-allow-headers |
✅ Supported | Sets the Access-Control-Allow-Headers response header. |
haproxy-haptic.org/cors-allow-methods |
✅ Supported | Sets the Access-Control-Allow-Methods response header. |
haproxy-haptic.org/cors-allow-origin |
✅ Supported | Sets the allowed origins (comma-separated, with a single-level *. wildcard); the matching request Origin is echoed back (default *). |
haproxy-haptic.org/cors-enable |
✅ Supported | Enables CORS response headers and answers OPTIONS requests with 204. |
haproxy-haptic.org/cors-expose-headers |
✅ Supported | Sets the Access-Control-Expose-Headers response header. |
haproxy-haptic.org/cors-max-age |
✅ Supported | Sets the Access-Control-Max-Age response header (default 86400). |
haproxy-haptic.org/denylist-source-range |
✅ Supported | Denies the listed CIDRs and allows all other source IPs for the host. |
haproxy-haptic.org/forwardfor |
✅ Supported | Controls the X-Forwarded-For header: add, update, ifmissing, or ignore. |
haproxy-haptic.org/response-cookie-domain |
✅ Supported | Rewrites the Domain attribute of upstream Set-Cookie response headers, given a <from> <to> pair, preserving the rest of the cookie string. Host-scoped; a wrong-arity value fails the render. |
haproxy-haptic.org/response-cookie-path |
✅ Supported | Rewrites the Path attribute of upstream Set-Cookie response headers, given a <from> <to> pair, preserving the rest of the cookie string. Host-scoped; a wrong-arity value fails the render. |
haproxy-haptic.org/response-location-rewrite-from |
✅ Supported | Names the literal text to match in the Location and Refresh response headers; the matched text is regex-escaped and replaced with the value of response-location-rewrite-to. Host-scoped. |
haproxy-haptic.org/response-location-rewrite-to |
✅ Supported | Supplies the replacement text for response-location-rewrite-from; required whenever a match pattern is set, or the render fails. |
haproxy-haptic.org/request-capture |
✅ Supported | Captures the named request headers (newline-separated) in the logs via capture request header, across the whole frontend. |
haproxy-haptic.org/request-capture-len |
✅ Supported | Sets the capture length for request-capture (default 128). |
haproxy-haptic.org/request-set-header |
✅ Supported | Sets request headers sent to the upstream, one <name> <value> per line. Reload-free: values move into ing-reqhdr.map, read by one static http-request set-header line per header name, keyed on the backend. |
haproxy-haptic.org/response-set-header |
✅ Supported | Sets response headers, one <name> <value> per line. Reload-free: values move into ing-reshdr.map, read by one static http-response set-header line per header name, keyed on the backend. |
haproxy-haptic.org/src-ip-header |
✅ Supported | Derives the client source IP from the named request header via http-request set-src. |
Canary and traffic mirroring¶
Header/cookie/weight-based canary routing and request mirroring via the SPOA hub.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/canary |
✅ Supported | Marks the Ingress as a canary for a host owned by another Ingress, overlaying a use_backend split instead of owning the route. |
haproxy-haptic.org/canary-by-cookie |
✅ Supported | Routes to the canary backend when the named cookie is present. |
haproxy-haptic.org/canary-by-header |
✅ Supported | Routes to the canary backend when the named header is present. |
haproxy-haptic.org/canary-by-header-pattern |
✅ Supported | Routes to the canary backend when the named header matches this regular expression; takes precedence over canary-by-header-value. |
haproxy-haptic.org/canary-by-header-value |
✅ Supported | Routes to the canary backend only when the named header equals this value. |
haproxy-haptic.org/canary-weight |
✅ Supported | Sends a percentage of traffic (an integer 0-100) to the canary backend via a weighted random split. |
haproxy-haptic.org/mirror-target |
✅ Supported | Mirrors requests fire-and-forget to a scheme://host[:port] target through the SPOA hub's mirror plugin, buffering the request body via option http-buffer-request; requires the mirror plugin and a host on the rule. |
Redirects, HSTS, passthrough, and config injection¶
HTTP→HTTPS and host redirects (reload-free maps), HSTS, SSL passthrough, a default backend, and raw section injection.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/root-redirect |
✅ Supported | Redirects requests for the host root path (/) to the given sub-path. |
haproxy-haptic.org/config-defaults |
✅ Supported | Injects raw operator-authored directives verbatim into the defaults section. |
haproxy-haptic.org/config-frontend |
✅ Supported | Injects raw operator-authored directives into every frontend, before routing. |
haproxy-haptic.org/config-global |
✅ Supported | Injects raw operator-authored HAProxy directives verbatim into the global section. |
haproxy-haptic.org/default-backend |
⚠️ Caveat | Routes requests that match the host but none of its configured paths to a named Service as a catch-all backend pool, using the Service's first port. Produces no backend, and no error, when the Service or its first port can't be resolved. |
haproxy-haptic.org/default-backend-redirect |
✅ Supported | Redirects requests that match the host but no path to the given URL. |
haproxy-haptic.org/default-backend-redirect-code |
✅ Supported | Sets the status code for default-backend-redirect (default 302). |
haproxy-haptic.org/apex-www-redirect |
✅ Supported | Issues a 301 redirect between the apex domain and its www subdomain, in both directions, preserving the request path and scheme. |
haproxy-haptic.org/hsts |
✅ Supported | Enables HSTS by adding the Strict-Transport-Security response header for the host. |
haproxy-haptic.org/hsts-include-subdomains |
✅ Supported | Appends includeSubDomains to the Strict-Transport-Security header when set to true. |
haproxy-haptic.org/hsts-max-age |
✅ Supported | Sets the HSTS max-age in seconds (default 63072000). |
haproxy-haptic.org/hsts-preload |
✅ Supported | Appends preload to the Strict-Transport-Security header when set to true. |
haproxy-haptic.org/permanent-redirect |
✅ Supported | Redirects the host to the given URL with a permanent status code (default 301). |
haproxy-haptic.org/permanent-redirect-code |
✅ Supported | Sets the status code for permanent-redirect (default 301). |
haproxy-haptic.org/ssl-passthrough |
✅ Supported | Passes TLS through to the backend without terminating it, routed by SNI on a dedicated TCP frontend. |
haproxy-haptic.org/https-redirect |
✅ Supported | Redirects plain HTTP requests for the host to HTTPS. Hosts that also set https-redirect-port are handled there instead, avoiding a double redirect. |
haproxy-haptic.org/https-redirect-code |
✅ Supported | Sets the HTTP-to-HTTPS redirect status code (301, 302, 303, 307, or 308; default 302). |
haproxy-haptic.org/https-redirect-port |
✅ Supported | Redirects plain HTTP requests to HTTPS on an explicit port, preserving the request URI. |
haproxy-haptic.org/temporary-redirect |
✅ Supported | Redirects the host to the given URL with a temporary status code (default 302). |
haproxy-haptic.org/temporary-redirect-code |
✅ Supported | Sets the status code for temporary-redirect (default 302). |
Authentication, mTLS, and WAF¶
Basic auth, client-certificate verification, external/forward auth, OAuth2-proxy, and the Coraza WAF via the SPOA hub.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/auth-headers-fail |
✅ Supported | Adds response headers on failed external authentication via http-after-response set-header. |
haproxy-haptic.org/auth-headers-request |
✅ Supported | Lists the request headers forwarded to the external authentication service. |
haproxy-haptic.org/auth-headers-succeed |
✅ Supported | Adds request headers to the upstream on successful external authentication. |
haproxy-haptic.org/auth-method |
✅ Supported | Overrides the HTTP method used for the external authentication subrequest. |
haproxy-haptic.org/auth-realm |
✅ Supported | Sets the basic-auth realm (default Restricted). |
haproxy-haptic.org/auth-secret |
✅ Supported | Names the Secret holding basic-auth credentials; an absent Secret skips the challenge. |
haproxy-haptic.org/auth-secret-type |
✅ Supported | Selects the credentials Secret format: auth-file (htpasswd in the auth key) or auth-map (one key per user); default auth-file. |
haproxy-haptic.org/auth-signin |
✅ Supported | Sets the sign-in redirect URL for failed external authentication. |
haproxy-haptic.org/auth-tls-cert-header |
✅ Supported | Forwards the client certificate details (X-SSL-Client-CN, X-SSL-Client-DN, X-SSL-Client-Cert) to the upstream when a client certificate was presented. |
haproxy-haptic.org/auth-tls-error-page |
✅ Supported | Redirects to the given URL when client-certificate (mTLS) verification fails. |
haproxy-haptic.org/auth-tls-secret |
✅ Supported | Enables client-certificate (mTLS) verification for the host using the CA in the named Secret; a host is required. |
haproxy-haptic.org/auth-tls-verify-client |
⚠️ Caveat | Sets client-certificate verification: on requires it, optional and optional_no_ca both map to verify optional (HAProxy has no distinct optional_no_ca mode), and off disables it. |
haproxy-haptic.org/auth-type |
✅ Supported | Enables basic authentication; the only accepted value is basic. |
haproxy-haptic.org/auth-url |
✅ Supported | Sets the external authentication service URL; requires the SPOA hub's external-auth plugin. |
haproxy-haptic.org/waf-policy |
✅ Supported | Selects one exact reusable Coraza policy. Definitions come from extraContext.waf.policies.inline, explicitly trusted ConfigMaps, or — with policies.selfService enabled — the Ingress's own namespace's well-known waf-policies ConfigMap; an Ingress can't define or redirect a source. Configuring any catalog source activates policy governance and Coraza automatically. |
haproxy-haptic.org/oauth |
✅ Supported | Enables authentication through oauth2-proxy (the only supported provider), building on external auth; skipped when auth-url is set. |
haproxy-haptic.org/oauth-headers |
✅ Supported | Lists headers forwarded from the oauth2-proxy response on success (default X-Auth-Request-Email). |
haproxy-haptic.org/oauth-uri-prefix |
✅ Supported | Sets the oauth2-proxy callback path prefix (default /oauth2). |
haproxy-haptic.org/satisfy |
✅ Supported | The value any grants access when either the source-IP allowlist or basic authentication passes, instead of requiring both. |
haproxy-haptic.org/waf-mode |
✅ Supported | Sets deny or detect, overriding the selected policy's enforcement only when waf.ingressPermissions.allowEnforcementOverride permits it. Requires a selected waf-policy. |
Reusable WAF policies¶
Reusable policies separate three responsibilities cleanly:
- The HAPTIC administrator chooses trusted policy sources and owns all Ingress override permissions; configuring the catalog activates policy governance automatically.
- A security team can maintain policy contents in a ConfigMap in a dedicated namespace.
- An Ingress author normally adds only
haproxy-haptic.org/waf-policy: <name>.
There are no route-selectable built-in profiles and no policy-definition annotation. A name is resolved exactly and case-sensitively against extraContext.waf.policies.inline plus the exact namespace/name/key triples in configMapRefs. A same-named ConfigMap in an application namespace is ignored — unless the administrator enables self-service authoring, which honors exactly one well-known ConfigMap per namespace, for that namespace's own Ingresses only. Duplicate names, missing sources, unknown fields, invalid SecLang, and unknown selections are rejected by the admission webhook; on a live render an unknown or broken selection fails that route closed with 503 and a Warning Event instead of aborting the whole render.
controller.config.templatingSettings.extraContext.waf.dispatch.mode controls the global activation model. The default opt-in mode sends only annotated routes to Coraza. default-on inspects all routes and uses dispatch.defaultEnforcement where no selected policy or authorized route override supplies an enforcement mode. This stays in extraContext because request dispatch is template-library behaviour and must also be configurable in a raw HAProxyTemplateConfig. Coraza's chart-wide directives and low-level plugin parameters remain under spoaHub.plugins.coraza.
The following example lets a security team own the catalog in the security namespace while application teams select approved policies:
controller:
config:
templatingSettings:
extraContext:
waf:
ingressPermissions:
allowPolicySelection: true
allowEnforcementOverride: false
allowWafDisable: false
allowCustomRules: false
allowRawHAProxyConfig: false
policies:
configMapRefs:
security:
namespace: security
name: haptic-waf-policies
key: policies.yaml
apiVersion: v1
kind: ConfigMap
metadata:
namespace: security
name: haptic-waf-policies
data:
policies.yaml: |
public-web:
description: Public browser applications without request-body inspection
requestBody:
mode: none
enforcement: deny
ruleExclusions:
- tags: [attack-sqli, attack-xss]
excludeTarget: "ARGS:q"
json-api:
requestBody:
mode: json
maxBytes: 4096
enforcement: deny
git-host:
description: Git smart-HTTP server — allowlist git's content type, don't disable the rule
requestBody:
mode: none
enforcement: deny
crsSettings:
allowedRequestContentTypes:
- application/x-git-upload-pack-request
- application/x-git-receive-pack-request
Each policy supports description, enforcement, nested requestBody.mode/maxBytes, allowedMethods, paranoiaLevel, anomalyThreshold, crsSettings, ruleExclusions, and secLang.
crsSettings fine-tunes a rule's inputs instead of disabling the rule — the preferred first response to a false positive. It's a curated allowlist of Open Worldwide Application Security Project (OWASP) Core Rule Set (CRS) tuning variables. allowedRequestContentTypes merges your media types into the standard CRS content-type allowlist — HAPTIC carries a copy of that default because upstream CRS ships it commented out, so it emits the full list (standard types plus yours), keeping rule 920420 active for every other content type. maxFileSize, maxNumArgs, and totalArgLength set a bounded scalar. The git example above is the model: adding the git application/x-git-upload-pack-request content type to the allowlist keeps 920420 active, where ruleExclusions: [920420] would switch content-type inspection off entirely. Reach for ruleExclusions only when a rule is categorically wrong for the application (for example CRS 930130 on a code host); it disables CRS rules by numeric ID — optionally scoped to a URL path by onPathPrefix, onPathSuffix, onPathExact, or onPathContains — or removes an exact variable such as ARGS:q from a rule or CRS tag. All of these work without application teams writing SecLang; secLang remains available to trusted policy authors for cases the structured fields can't express.
requestBody.mode: none inspects metadata without buffering or limiting uploads. any inspects a complete bounded body; json additionally requires a JSON media type. Body routes require an unambiguous Content-Length; oversized or incomplete bodies are rejected before Coraza. A policy that omits requestBody.maxBytes uses policies.requestBody.defaultMaxBytes; it may never exceed policies.requestBody.maxBytes. Keeping those two settings separate lets an administrator approve one larger policy without silently enlarging every policy that relied on the default. The effective per-policy cap is set both in HAProxy and in that Coraza application, so neither layer silently inspects a different amount. Template body behavior lives under extraContext.waf.policies.requestBody; SPOA timeout/concurrency live only under spoaHub.plugins.coraza; the process-global HAProxy buffer lives under extraContext.requestBodyInspection.haproxyBuffer.
WAF and gRPC streaming¶
Set requestBody.mode: none on any route carrying gRPC client-streaming or bidirectional-streaming calls. Metadata inspection — method, path, headers, source IP — still runs, so the route keeps WAF coverage of everything the engine can actually read.
This isn't a HAPTIC limitation to work around. Coraza buffers a complete request body because that's what makes blocking reliable, and it ships body processors for urlencoded, multipart, JSON, and (partially) XML — there is no protobuf or gRPC processor. So even a fully buffered gRPC body is an opaque length-prefixed binary blob to the rule set: CRS finds nothing in it, while every byte still costs buffering. Other Coraza integrations hit the same wall and say so plainly — Solo's WAF server doesn't support streaming either, and ModSecurity has carried an open request to parse gRPC bodies since 2021.
A body-inspecting mode (any or json) therefore can't be combined with a streaming route. Those modes wait for a complete, bounded body, and a streaming request never provides one: it declares no Content-Length and holds the body open until the peer is done. The wait runs to policies.requestBody.waitTimeout and HAProxy answers 408, having never contacted the backend. That's fail-closed, not a bypass — a body the WAF can't bound is never forwarded uninspected — but the route stops working, so pick none deliberately rather than discovering it in production.
Unary gRPC is unaffected in every mode: its body is complete on arrival, so the wait returns immediately.
Detect (shadow) mode never blocks a streaming request. It waits only for a body whose length is declared and leaves an unbounded one uninspected, so switching a buffered policy to detect can't take a streaming route down. A declared body is still buffered in detect mode, keeping shadow verdicts faithful to what enforcement would have decided. Where the wait is skipped the body is reported to the engine as incomplete, so a shadow verdict is never computed over a partly arrived request and then read as a sign that enforcement would have been safe.
HAPTIC enforces this rather than leaving it to be discovered in production. An Ingress that declares a gRPC backend (haproxy-haptic.org/backend-protocol: grpc/grpcs, or the nginx-compat GRPC/GRPCS) and selects a policy with a body-inspecting requestBody.mode is rejected when you apply it, with a message naming the policy and the fix. A route that already carries the combination isn't taken down: it records a Warning Event with reason WafBodyPolicyOnGRPCRoute and keeps serving, because the runtime already refuses exactly the calls it can't inspect and the unary ones are inspected correctly — there's nothing to fail closed.
Plain h2/h2-ssl backends aren't affected. HTTP/2 to the backend is how gRPC travels, but it's equally an ordinary HTTP API backend whose bodies are bounded and can be inspected, so the check keys on the unambiguous gRPC declarations only.
Don't reach for partial-body inspection. Coraza's SecRequestBodyLimitAction ProcessPartial truncates at the limit and runs the rules on what it has, which looks like a way to inspect a stream. It isn't: an attacker prepends padding up to the inspected size and the payload lands in the uninspected remainder. Coraza documents that bypass, and HAPTIC's reject posture is deliberate.
Because body rules can't protect a streaming route, protect it with the controls that don't need the body — all available as annotations on the same route:
| Control | Annotation |
|---|---|
Per-method authorization (a gRPC path is /package.Service/Method) |
allowed-methods, allowlist-source-range |
| Caller identity | jwt-*, api-key-*, client mTLS |
| Abuse and volume limits | rate-limit-* |
| Message size cap | max-request-body-size |
For an immutable cluster baseline, configure a default and disable selection:
controller:
config:
templatingSettings:
extraContext:
waf:
ingressPermissions:
allowPolicySelection: false
allowEnforcementOverride: false
allowWafDisable: false
allowCustomRules: false
allowRawHAProxyConfig: false
policies:
defaultPolicy: public-web
Removing all WAF annotations doesn't remove this default. HAPTIC also rejects HAPTIC and vendor annotations that select another application, switch to detect mode, disable the WAF, inject SecLang, or inject raw HAProxy configuration. Raw configuration is checked cluster-wide because a frontend, defaults, or global snippet from one Ingress can short-circuit processing for other routes.
The safe defaults are allowEnforcementOverride: false, allowWafDisable: false, allowCustomRules: false, and allowRawHAProxyConfig: false. Enforcement-mode overrides and complete WAF opt-outs are deliberately separate permissions: allowing an application team to choose deny/detect doesn't also let it disable inspection. Turning on allowCustomRules grants every Ingress writer arbitrary SecLang capability, including directives that can disable or rewrite policy rules. Turning on allowRawHAProxyConfig grants every Ingress writer HAProxy-configuration-administrator capability. Use those switches only where Ingress write access is already trusted at that level. allowPolicySelection: true authorizes every Ingress writer to choose any policy in the approved catalog; set it to false with a defaultPolicy when that's too broad.
Protect every referenced ConfigMap with Kubernetes RBAC. Anyone who can update one is a WAF policy author. HAPTIC intentionally can't infer the human identity or RBAC path behind a ConfigMap update; the chart establishes the exact source boundary, while Kubernetes authorizes writers to that source.
A policy's directives compile in a deterministic order: chart-wide Coraza/CRS directives, the policy's setup-position tuning (paranoia level, allowed methods, path-scoped rule exclusions) before the CRS include, its config-time rule exclusions after, then HAPTIC's non-overridable body-safety directives. Policies compile once and are shared across every route that selects them. The nginx-compatible custom-rule path (nginx.ingress.kubernetes.io/modsecurity-snippet, documented on the nginx-ingress page) creates a private per-Ingress Coraza application and shares waf.customRules.limits.maxIngresses and maxBytesPerIngress; these DoS bounds apply even when no reusable policy catalog is configured.
Self-service namespaced policies¶
waf.policies.selfService lets every namespace author WAF policies for its own Ingresses without any per-namespace registration — the admin enables the mode once, and each team owns one well-known ConfigMap (default name waf-policies, data key policies.yaml) in its namespace:
apiVersion: v1
kind: ConfigMap
metadata:
namespace: team-a
name: waf-policies
data:
policies.yaml: |
app-baseline:
requestBody:
mode: none
enforcement: detect
ruleExclusions:
- tags: [attack-sqli]
excludeTarget: "ARGS:q"
An Ingress in team-a then selects haproxy-haptic.org/waf-policy: app-baseline — the same annotation as trusted policies. Names resolve against the trusted catalog first, then the Ingress's own namespace; a policy defined in another namespace is invisible, and explicit cross-namespace addressing (team-a/app-baseline) is rejected.
Self-service stays safe for the shared data plane by construction:
- Namespace-scoped identity. A self-service policy can't collide with, shadow, or hijack another namespace's or the trusted catalog's names. A name that clashes with a trusted policy is never resolved silently in either direction: the clashing namespace's selectors fail closed while other namespaces still get the trusted policy.
- Scoped failure. A broken catalog (invalid YAML) or invalid policy records Warning Events (
WafPolicyCatalogInvalid/WafPolicyInvalid) on the ConfigMap, and only that namespace's selecting routes fail closed with503— the global render, and every other team, continue untouched. The admission webhook still rejects a change that would introduce the breakage, so the fail-closed path only covers breakage that pre-dates the webhook or raced past it. - Bounded content.
secLangis refused unless the administrator setsselfService.allowSecLang: true— the structured fields (enforcement,requestBody,allowedMethods,paranoiaLevel,anomalyThreshold,crsSettings,ruleExclusions) cover false-positive tuning without arbitrary rule code in the shared Coraza process.requestBodystays bounded by the administrator'spolicies.requestBody.maxBytesceiling, andselfService.limits.maxPoliciesPerNamespace/maxTotalPoliciescap catalog growth (cuts are deterministic: sorted namespaces, sorted names). Note the caps bound size and count, not rule CPU — that's whyallowSecLangis a separate, off-by-default grant. - The baseline stays admin-owned.
defaultPolicyresolves in the trusted catalog only, and underdefault-on/denydispatch a self-service policy whose effective enforcement isdetectis rejected — a tenant can't weaken the cluster baseline.
Enabling self-service activates WAF governance (the ingressPermissions gates), the Coraza plugin, and a dedicated, name-scoped ConfigMap watch (only the well-known catalogs are retained in memory, not every cluster ConfigMap). Use configMapRefs instead when a central security team authors policies for other teams, and inline policies for admin-only catalogs; all three sources compose.
API gateway¶
API-management controls expressed as pure HAProxy config plus low-latency SPOA plugins where HAProxy can't do the work natively: token authentication (API key, JWT, HMAC) that establishes a shared consumer identity, consumer-group authorization, stateless request gating (method/content-type/header validation, mocking, termination), JSON request-body validation, and request correlation IDs.
JWT and API-key auth both set a shared txn.haptic_consumer identity (JWT from the sub claim, API key from its map), which consumer-group authorization and — in later releases — per-consumer quotas build on.
JSON request-body validation is opt-in via controller.config.templatingSettings.extraContext.apiGateway.requestSchemaValidation.enabled=true. Schemas are resolved from ConfigMaps or Secrets and compiled when the bundled plugin initializes/reloads. HAProxy rejects bodies above the route cap before SPOE, waits up to requestBody.waitTimeout only on matching POST/PUT/PATCH routes, and then validates against an in-memory compiled schema. The process-global tune.bufsize comes from extraContext.requestBodyInspection.haproxyBuffer.sizeBytes; reservedBytes (default 8192) protects request headers and rewrite space. Any validator or policy body cap above the remaining capacity fails. Requests without Content-Length return 411, duplicate lengths return 400, and incomplete buffering returns 413 instead of validating truncated input. Request-body transformation isn't supported.
haproxy-haptic.org/request-schema-max-body-size is a validator input cap, not the general upload/body-size policy. Use haproxy-haptic.org/max-request-body-size when you want to limit the body size a backend may receive. Use request-schema-max-body-size to bound how much body data HAProxy may pass to the API-gateway validator and how much JSON the plugin may parse. If both apply to a validated POST/PUT/PATCH request, either one may return 413; in practice the stricter applicable limit wins.
| Annotation | Status | Behaviour |
|---|---|---|
haproxy-haptic.org/allowed-consumer-groups |
✅ Supported | Comma-separated group set the route permits; a request whose consumer isn't in an allowed group is denied 403. Requires consumer-groups-secret and an authenticated consumer. |
haproxy-haptic.org/allowed-methods |
✅ Supported | Restricts the accepted HTTP methods (comma-separated); any other method is denied with 405. |
haproxy-haptic.org/api-key-consumer-header |
✅ Supported | Forwards the resolved consumer id to the upstream in the named header. |
haproxy-haptic.org/api-key-header |
✅ Supported | Header carrying the API key (default X-API-Key); mutually exclusive with api-key-query. |
haproxy-haptic.org/api-key-query |
✅ Supported | Query parameter carrying the API key; mutually exclusive with api-key-header. |
haproxy-haptic.org/api-key-secret |
✅ Supported | Names the Secret (data key keys, one apikey[:consumer] per line) that becomes a reload-free key→consumer map; an unknown key is denied with 401, and a valid key sets the shared txn.haptic_consumer identity. Fails closed (503) while the Secret is absent. |
haproxy-haptic.org/consumer-groups-secret |
✅ Supported | Names the Secret (data key groups, one <consumer>:<group> per line) mapping each consumer to a group; combined with allowed-consumer-groups to authorize. Requires an authenticated consumer and fails closed (503) while the Secret is absent. |
haproxy-haptic.org/hmac-algorithm |
✅ Supported | HMAC digest algorithm (default sha256; sha1/sha224/sha384/sha512). |
haproxy-haptic.org/hmac-header |
✅ Supported | Header carrying the client HMAC signature (default X-Signature; lowercase hex). |
haproxy-haptic.org/hmac-secret |
⚠️ Caveat | Names the Secret (data key secret) for HMAC request-signature verification (deny 401 on mismatch). The shared key is inlined (base64) into the rendered config and the compare isn't constant-time — prefer JWT. Fails closed (503) when the Secret is absent. |
haproxy-haptic.org/hmac-signed-string |
✅ Supported | What the signature covers: body (default, buffers the request) or path. |
haproxy-haptic.org/jwt-algorithm |
✅ Supported | JWT signature algorithm (default RS256); asymmetric only (RS/ES/PS 256/384/512) — symmetric HS* is rejected so no shared secret is inlined. |
haproxy-haptic.org/jwt-audience |
⚠️ Caveat | Required aud claim value (exact match); an array aud (multiple audiences) isn't matched — scalar only. |
haproxy-haptic.org/jwt-forward-claims |
✅ Supported | Comma-separated <claim>:<header> pairs forwarded upstream after verification; each header is stripped from the client request first (anti-spoof). |
haproxy-haptic.org/jwt-issuer |
✅ Supported | Required iss claim value (exact match). |
haproxy-haptic.org/jwt-required-claims |
✅ Supported | Comma-separated claim names that must be present in the payload; a missing claim is denied 401. |
haproxy-haptic.org/jwt-secret |
✅ Supported | Names the Secret (data key pubkey.pem) for asymmetric JWT verification with an alg-confusion guard, exp/iss/aud/required-claim checks, and the shared consumer identity from sub. Fails closed (503) when the Secret is absent; key rotation needs a reload. |
haproxy-haptic.org/mock-response |
✅ Supported | A non-empty value returns it as a canned response body, short-circuiting the backend (for stubbing an API). |
haproxy-haptic.org/mock-response-code |
✅ Supported | HTTP status for mock-response (default 200). |
haproxy-haptic.org/mock-response-content-type |
✅ Supported | Content-Type for the mock-response body (default application/json). |
haproxy-haptic.org/request-id |
✅ Supported | The value true generates a per-request correlation id and forwards it upstream (HAProxy unique-id). |
haproxy-haptic.org/request-id-accept-inbound |
✅ Supported | The value true preserves a client-supplied id (used only when the header is absent) instead of always generating a fresh one. |
haproxy-haptic.org/request-id-header |
✅ Supported | Header carrying the correlation id (default X-Request-ID). |
haproxy-haptic.org/request-schema-configmap |
✅ Supported | Enables JSON request-body validation using a ConfigMap schema reference: [namespace/]name[:key], default key schema.json. Exactly one schema source is required. Requires extraContext.apiGateway.requestSchemaValidation.enabled=true. |
haproxy-haptic.org/request-schema-content-types |
✅ Supported | Comma-separated accepted media types for the schema (default application/json). The plugin strips ; charset=... parameters before matching; mismatches return 415. |
haproxy-haptic.org/request-schema-fail-open |
✅ Supported | Per-route policy for missing plugin verdicts/schema ids (true or false, default from extraContext.apiGateway.requestSchemaValidation.defaultFailOpen, chart default true). The default allows the request and records schema_degraded; false returns 422 with denied_by=schema_unavailable. |
haproxy-haptic.org/request-schema-max-body-size |
✅ Supported | Per-route validator input cap (1..1048576; default requestSchemaValidation.requestBody.defaultMaxBytes, chart default 8192). It must fit within requestBodyInspection.haproxyBuffer.sizeBytes - reservedBytes. Oversized requests return 413 before SPOE. This doesn't replace haproxy-haptic.org/max-request-body-size, the general backend body-size limit. |
haproxy-haptic.org/request-schema-secret |
✅ Supported | Enables JSON request-body validation using a Secret schema reference: [namespace/]name[:key], default key schema.json. The Secret data value must be base64-encoded JSON Schema. Exactly one schema source is required. |
haproxy-haptic.org/fixed-response |
✅ Supported | The value true returns a fixed response for every request matching the route's hosts via http-request return — for maintenance windows or sunset routes. Runs before mocking and the validators. Defaults to status 503 / text/plain, and can return a bare status with no body. |
haproxy-haptic.org/fixed-response-body |
✅ Supported | Optional response body for fixed-response. |
haproxy-haptic.org/fixed-response-code |
✅ Supported | HTTP status for fixed-response (default 503; must be 100-599). |
haproxy-haptic.org/fixed-response-content-type |
✅ Supported | Content-Type for the fixed-response body (default text/plain). |
haproxy-haptic.org/require-content-type |
✅ Supported | Requires an allowed Content-Type (comma-separated) on body methods (POST/PUT/PATCH); a disallowed type is rejected with 415 (prefix-matched, so charset suffixes still match). |
haproxy-haptic.org/require-headers |
✅ Supported | Requires the listed request headers (comma-separated); a request missing any is rejected with 400. |
Access-log fields¶
The library contributes these fields to the structured access log, each only when the corresponding annotation or feature is in use:
| Field | Contributed when | Meaning |
|---|---|---|
consumer |
any resource sets jwt-secret or api-key-secret |
Authenticated consumer identity — the key a per-consumer rate limit buckets on |
cache, app_backend |
the Varnish tier is enabled | Varnish's HIT/MISS/STALE verdict, and the application backend the route resolved to (the core backend field reads varnish_cache for cached routes) |
client_ip_peer |
any resource sets src-ip-header |
The real TCP peer, which is how you spot a client claiming an address it doesn't own once set-src has rewritten client_ip |
captured_headers |
any resource sets request-capture |
The captured request headers |
mtls_verify, mtls_cn |
any resource sets auth-tls-secret or auth-tls-cert-header |
The certificate verification result (0 on success, otherwise an X509 error code) and the client's CN |
The presented API key, the computed HMAC signature and the full client certificate are deliberately never logged.