Skip to content

Header-based routing predicates on RequestHttpFrontend #1253

Description

@Shine-neko

Hello,

After the header-mutation work landed in #1231, the closing notes of
#596 flagged header-based routing as the remaining gap: the router
still keys on (hostname, path, method) only, and a fresh narrowly-scoped
issue was suggested as the cleaner path forward (rather than folding
into #1239's general programmable hooks).

This is that issue. It proposes a declarative header-predicate
surface on RequestHttpFrontend, distinct from #1239's hooks: a static
config field that any provider (CLI, config file, command socket
consumer) can fill without writing code.

Use case (Sozune): Sozune is a Traefik-style higher-level proxy on
top of sozu-lib (referenced in #1239). Today it can express
Host(...), PathPrefix(...), Method(...) — equivalents to Traefik's
matchers — but Headers(name, value) and HeaderRegexp(name, regex)
are blocked at the RequestHttpFrontend boundary. Concrete operator
labels would look like:

sozune.http.api.match.headers.X-Tenant=acme
sozune.http.api.match.headers.X-Region~=^eu-

This is a recurrent ask on the Traefik-comparison axis (canary by header,
B2B per-tenant routing, gRPC service split via :authority + custom
headers, A/B by X-Variant, etc.).

Why declarative, not via #1239

#1239 (programmable Rust hooks) would technically cover this — write a
hook that inspects headers and returns a routing decision. But for
header-equality / regex / presence checks specifically:

  • Every operator using header-based routing would re-implement the same
    predicate evaluator, with diverging semantics.
  • The configuration round-trip (sozuctl, command socket, config file)
    loses the predicate — it's hidden inside compiled-in Rust code.
  • state.rs reconciliation, frontend listing, and operational tooling
    can't see why a frontend was chosen.

A declarative predicate keeps parity with how path, method, and
hostname are already expressed: serializable, reloadable, listable.
#1239 remains the right home for arbitrary logic (token validation,
dynamic backend selection, custom rewrite rules); header equality /
regex / presence is the long-tail that deserves first-class support.

Proposed proto change

Add one field to RequestHttpFrontend in command/src/command.proto:

message RequestHttpFrontend {
    // ... existing fields 1..16 unchanged ...

    // Additional header predicates that must all hold for this
    // frontend to match. Empty = no header constraint (current
    // behaviour). Names are matched case-insensitively per RFC 9110
    // §5.1, consistent with the existing `Header` mutation type.
    repeated HeaderMatch match_headers = 17;
}

message HeaderMatch {
    // Header name, case-insensitive (RFC 9110 §5.1).
    required string name = 1;
    // Exactly one predicate must be set.
    oneof predicate {
        // Exact value match (byte-for-byte after trimming OWS).
        string exact = 2;
        // RE2-compatible regex applied to the full header value.
        string regex = 3;
        // Header must be present; value is not inspected.
        bool present = 4;
    }
}

Semantics

  1. Combination: all entries in match_headers must hold (logical
    AND), matching Traefik's Headers(...) semantics. OR is expressed
    by registering multiple frontends with the same (host, path, method).

  2. Multi-valued headers: a request header repeated N times (or
    comma-folded) matches if any of its values satisfies the
    predicate. This avoids surprises with Accept, Cookie, Forwarded.

  3. Precedence vs. unconstrained frontends: when several frontends
    match (hostname, path, method), the one with a non-empty
    match_headers is preferred over one without. Among frontends with
    non-empty match_headers, current RulePosition ordering applies,
    then the more specific (longer match_headers) wins. Without this
    rule, a header-less fallback frontend always shadows a
    header-specific one — defeats the feature.

  4. Empty list: preserves current behaviour exactly. No migration
    for existing deployments.

Router implementation sketch

The (hostname, path, method) lookup in lib/src/router/mod.rs already
returns a Vec<HttpFront> candidate set (per #571 closing notes — domain
trie returns a vec, path/method linearly tested). Header predicates
slot into that linear post-filter:

  1. Trie lookup → candidate vec (unchanged).
  2. Existing path + method filter (unchanged).
  3. New: for each remaining candidate with a non-empty
    match_headers, evaluate predicates against HttpContext headers.
    Drop the candidate if any predicate fails.
  4. Sort surviving candidates by (header-specific first, then
    RulePosition, then predicate count) and pick the first.

Headers are already fully parsed before Router::connect runs (post
#1231, kawa H1 + Mux session layer), so no buffering changes are needed.
RE2 is already a transitive dep via regex in the kawa parsing path.

Out of scope (deliberately)

  • Header value transformations / capture groups in routing (handled by
    the existing headers mutation field).
  • Body-based routing.
  • Routing on connection metadata (client IP, TLS SNI beyond hostname) —
    those deserve their own predicates if/when needed.
  • WASM / dynamic plugins (WebAssembly plugins #685, Programmable Rust hooks for sōzu — design proposal #1239).
  • Query-string predicates — same shape, separate ticket to keep this
    one focused.

Open questions for maintainers

  1. Field tag: is 17 acceptable on RequestHttpFrontend, or do you
    want a reserved range for future predicates?
  2. Regex flavour: confirm RE2 (via regex crate) is the right
    choice rather than PCRE-flavoured syntax.
  3. Trimming: match against trimmed value (OWS removed per RFC 9110
    §5.5) or raw bytes? Traefik trims; HAProxy doesn't.
  4. Forbidden header names: should we reject predicates on
    Host (already routed on) and Cookie (sticky-session collision
    risk) at config-load, or trust operators?
  5. Implementation appetite: would a PR from the Sozune side be
    welcome, or is this on the Sōzu roadmap and a contribution would
    conflict?

Refs

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions