Skip to content

Per-flow fingerprint store via PROXY v2 Custom TLV #352

Description

@pigri

Goal

The edge proxy operates in TLS passthrough mode (Cloudflare-less demo architecture: external client → edge SNI router → Tier-2 proxy that terminates TLS). The edge sees the raw TLS bytes and can extract JA4/JA4T fingerprints from the ClientHello. The Tier-2 proxy sees the cleartext but loses the L4/TLS fingerprint signal.

PR #350 wired PROXY v2 end-to-end so the Tier-2 proxy can recover the real client IP. The natural extension is to ride per-connection fingerprints in the same v2 header — populating the existing fp_lookup cache on the Tier-2 proxy without an out-of-band channel.

Design

In-band, via a PROXY v2 Custom TLV in the application-defined 0xE0..=0xEF range.

Edge side (encoder — passthrough.rs)

After the existing SNI peek, also call synapse_core::utils::tls_fingerprint::fingerprint_client_hello(&buf[..n]) to extract a Fingerprint (JA4 / JA4 unsorted / TLS version / cipher suite / SNI / ALPN). Serialize compact JSON (~250 bytes), wrap in ExtensionTlv::Custom { type_id: 0xE0, value: bytes }, and append to the v2 header extensions list before encode. Only fires for routes with proxy_protocol_v2: true — same opt-in as the existing source-addr emission.

Tier-2 side (receiver — start.rs)

At startup, register a callback via pingora_core::set_proxy_v2_tlv_callback:

pingora_core::set_proxy_v2_tlv_callback(Some(|tlvs, real_addr| {
    for tlv in tlvs {
        if let ExtensionTlv::Custom { type_id: 0xE0, value } = tlv {
            if let Ok(fp) = serde_json::from_slice::<Fingerprint>(value) {
                if let SocketAddr::Inet(addr) = real_addr {
                    fp_lookup::put_self(addr.ip(), addr.port(), Arc::new(fp));
                }
            }
        }
    }
}));

Once the entry is in fp_lookup keyed by the PROXY v2-recovered (client_ip, client_port), the existing proxyhttp.rs::request_filter lookup at line ~580 (crate::fp_lookup::get(peer_addr.ip(), peer_addr.port())) picks it up automatically — the WAF, access log, and rate limiter all see JA4 even though the TLS bytes never reach the Tier-2 proxy in cleartext.

Cross-repo dependencies

This work depends on two upstream fork PRs that need to land first:

  1. feat(v2): expose application-defined TLV range (0xE0..=0xEF) proxy-protocol#12 — adds ExtensionTlv::Custom { type_id, value } for the 0xE0..=0xEF application range. Without this, the crate's enum rejects custom TLV types as InvalidTlvTypeId.
  2. feat(pingora-core): expose PROXY v2 extension-TLV callback pingora#22 — adds set_proxy_v2_tlv_callback hook in UninitializedStream::handshake. Without this, parsed TLVs are silently dropped by maybe_consume_proxy_header after the source addr is recovered.

Both PRs are open and tested.

Suggested commit sequence

  1. Merge feat(v2): expose application-defined TLV range (0xE0..=0xEF) proxy-protocol#12, tag a release if appropriate.
  2. Update feat(pingora-core): expose PROXY v2 extension-TLV callback pingora#22's pingora-core/Cargo.toml to point at proxy-protocol main (currently pinned to the feature branch). Merge feat(pingora-core): expose PROXY v2 extension-TLV callback pingora#22.
  3. In synapse: repoint [patch.crates-io] for the 16 pingora-* crates from branch = "main" (which is where it sits today, getting the source-addr-only PROXY v2 path) — no change needed since feat(pingora-core): expose PROXY v2 extension-TLV callback pingora#22 just adds to that same branch on merge. Bump Cargo.lock.
  4. Add synapse_proxy::passthrough::encode_fingerprint_tlv and call it from handle_conn after extract_sni.
  5. Add synapse_proxy::proxy_protocol::tlv_callback and register it in synapse_proxy::start::run next to set_proxy_protocol_enabled.
  6. Configure proxy_protocol_v2: true on the demo passthrough routes (already set) and verify Axiom's synapse.access.fingerprints.ja4* populates for HTTPS traffic to test-a.

Out of scope for this issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions