TT Lab
Get started
Learn Learning paths Courses

Envoy Internals

Chains Win by Specificity, Not Order

Continue in TT Lab

In one line

A listener is "one address and port", and the filter chain inside it is what actually accepts the connection. Which chain accepts is decided by the properties of the connection (SNI, transport protocol, ALPN, source), and that decision narrows down through eight steps in a fixed order. The order you wrote in the configuration makes no difference.

Why this was needed

One ingress gateway accepts dozens of domains. Each domain has a different certificate, some domains must be accepted over HTTP/2, and the internal health check comes in as plain text. You could solve this by starting a separate process for each port, but then you have to hand out that many addresses and the configuration is scattered just as much.

Envoy took a different path. Keep one port, and look at the properties of the connection that arrived on that port and send it to a different chain. So a single 443 presents a different certificate for each domain, and the same port can also accept plain-text requests.

If you do not know this structure, you get stuck in front of two symptoms. One is "I definitely added the certificate, but only this domain's connection is just cut", and the other is "I moved the chain up, but the one below still accepts it". Both have the same cause — misunderstanding the chain selection rule as an order.

How it works

When a connection comes in, the listener filters run first. These filters only read bytes so far and change nothing. What tls_inspector does among them is the key. The first message of the TLS handshake (the ClientHello) is plain text, so it can be read without decryption, and it contains the SNI (the name being connected to) and the ALPN (the list of protocols it wants to use). The inspector pulls out those two and attaches them to the connection, and as a bonus it also decides "is this connection TLS or not" and sets transport_protocol to tls or raw_buffer.

If you do not turn this filter on, the server_names and application_protocols conditions match no connection at all. The configuration passes and only the behavior fails, so it is the hardest kind of mistake to find.

Then the matching runs. The order written in the official documentation is this.

1. 목적지 포트        2. 목적지 IP
3. 서버 이름(SNI)     4. 전송 프로토콜
5. 애플리케이션 프로토콜(ALPN)
6. 직접 연결된 출발지 IP   7. 출발지 유형
8. 출발지 IP          9. 출발지 포트

At each step, only the chain that matches most specifically moves on to the next step. A chain that wrote no condition survives, because it means "I will accept anything", but a chain that wrote a condition and got it wrong is eliminated on the spot. After all the steps, at most one chain is guaranteed to remain.

One conclusion from this often trips people up in practice. The SNI condition comes before the ALPN condition. So if there is a chain that wrote only server_names: ["a.example.com"] and a chain that wrote only application_protocols: ["h2"], when an HTTP/2 request comes in for a.example.com, the SNI chain accepts it. This is exactly where people say "I meant to send h2 to the other chain".

Wildcards are compared by specificity too. When the SNI is www.example.com, the priority is www.example.com → *.example.com → *.com → no condition.

What it looks like in the field

"Only this domain's connection is cut." The clue is that there is no response code. A 404 or 503 means it got as far as the HTTP layer, but if no chain matches, the connection is closed before that, so the client sees only a TLS handshake failure. If you add the certificate for a new domain and forget to add the name to server_names, it looks exactly like this.

"The result is the same even if I change the chain order." Of course. Order is not used in matching. What you need to change is the specificity of the conditions.

A setup that accepts plain text and TLS on one port. Inside Kubernetes, when you want to accept health checks as plain text and traffic from outside as TLS, you solve it by adding one more chain with transport_protocol: raw_buffer. It is much simpler than opening one more port and creating one more firewall rule.

Official documentation: Listeners · FilterChainMatch · TLS Inspector

What you will do in the next lab

You bake two certificates yourself and split chains by SNI, accept plain text and TLS together on the same port, and check for yourself which one wins when SNI and ALPN both match. At the end you send a request that matches no chain and see "silence, not a response" with your own eyes.