TT Lab
Get started
Learn Learning paths Courses

Envoy Internals

Split One Port With Two Certificates

Continue in TT Lab

Goal

Put several filter chains on one listener and split what they accept by SNI, transport protocol and ALPN. You also check for yourself which one wins when conditions overlap.

Why it matters

In a setup where one ingress gateway accepts dozens of domains, "which chain accepts this connection" is "which certificate gets presented". If you misunderstand this rule as an order, you end up spending a day moving chains up and down. The actual decision is made by comparing specificity through eight steps in a fixed order, and if no chain matches, you get not an error response but a connection that is simply cut — if you go through these two things by hand, you can close an "only this domain does not work" report in a few minutes.

Steps

  1. In /root/envd-listener/listener.yaml, put two listeners — edge-a returns listener=a at 127.0.0.1:10021 and edge-b returns listener=b at 127.0.0.1:10022, both with direct_response (the admin port is 9921). After you start it, save /listeners?format=json to /root/envd-listener/01-listeners.json.
  2. Create two self-signed certificates in /root/envd-listener/tls/ — a.crt/a.key have CN and SAN a.envd.test, and b.crt/b.key have b.envd.test. Write the subject and SAN of the two certificates to /root/envd-listener/02-certs.txt.
  3. Add a listener mixed (127.0.0.1:10443) to /root/envd-listener/listener.yaml. Turn on the tls_inspector listener filter and split two TLS chains by server_names — a.envd.test returns chain=sni-a and b.envd.test returns chain=sni-b. Write the results of requesting with each of the two names to /root/envd-listener/03-sni.txt as two lines, a= and b=.
  4. Connect to 10443 twice with openssl s_client, changing only -servername, and write which certificate comes out each time to /root/envd-listener/04-cert.txt as two lines, a= and b= (the value is the certificate's CN).
  5. Add a chain plain that matches transport_protocol: "raw_buffer" to the mixed listener, at the very front, and make it return chain=plain. Send a plain HTTP request and a TLS request to the same port 10443 and write the results to /root/envd-listener/05-raw.txt as two lines, plain= and tls=.
  6. Add a fourth chain alpn-h2 to mixed — it matches only transport_protocol: "tls" and application_protocols: ["h2"], and returns chain=alpn-h2 over HTTP/2. Then request with the SNI set to a.envd.test, over HTTP/2 and check which chain accepts it, and request with the SNI set to z.envd.test, over HTTP/2 and check which chain accepts it, and write them to /root/envd-listener/06-alpn.txt as two lines, sni_and_h2= and h2_only=.
  7. Send a HTTP/1.1 TLS request to z.envd.test — it matches no chain. In /root/envd-listener/07-nomatch.txt, write two lines: nomatch_rc= (the curl exit code of that request) and match_rc= (the exit code of the same request sent to a.envd.test).
  8. In /root/envd-listener/08-report.md, write four lines chains= (the number of chains of the mixed listener), same_port_plain_and_tls= (yes or no), sni_beats_alpn= (the name of the chain that won when SNI and ALPN both matched in step 6) and nomatch_rc=, and below them write what you learned in at least four lines.

Notes

One process listens on two ports

In /root/envd-listener/listener.yaml, put two listeners — edge-a returns listener=a at 127.0.0.1:10021 and edge-b returns listener=b at 127.0.0.1:10022, both with direct_response (the admin port is 9921). After you start it, save /listeners?format=json to /root/envd-listener/01-listeners.json.

A listener is a unit of "one address and port". One process can hold several, and each listener has its own list of filter chains. If you use direct_response, responses go out without an upstream, so you do not need to start a backend in this lab. /listeners on the admin port is text by default, so you need to add ?format=json to get the structure.

Bake two certificates with different names

Create two self-signed certificates in /root/envd-listener/tls/ — a.crt/a.key have CN and SAN a.envd.test, and b.crt/b.key have b.envd.test. Write the subject and SAN of the two certificates to /root/envd-listener/02-certs.txt.

To split chains by SNI you need at least two certificates with different names. One line of openssl req -x509 -newkey rsa:2048 -nodes gives you the key and the certificate together. Do not leave out -addext "subjectAltName=DNS:..." — today's clients do not look at the CN, only at the SAN. Check with openssl x509 -noout -subject -ext subjectAltName.

Same port, but a different chain accepts depending on the name

Add a listener mixed (127.0.0.1:10443) to /root/envd-listener/listener.yaml. Turn on the tls_inspector listener filter and split two TLS chains by server_names — a.envd.test returns chain=sni-a and b.envd.test returns chain=sni-b. Write the results of requesting with each of the two names to /root/envd-listener/03-sni.txt as two lines, a= and b=.

The SNI is in plain text in the first message of the TLS handshake. So it can be read even before decryption, and what does that job is the tls_inspector listener filter. If you do not turn this filter on, the server_names condition matches no connection at all. Send the request with curl -k --resolve a.envd.test:포트:127.0.0.1 https://... (the placeholder is the port).

When the chains split, the certificates presented split too

Connect to 10443 twice with openssl s_client, changing only -servername, and write which certificate comes out each time to /root/envd-listener/04-cert.txt as two lines, a= and b= (the value is the certificate's CN).

Each chain has its own transport_socket, so which chain is picked is which certificate is presented. This is why one ingress gateway can hold the certificates of dozens of domains. Check with echo | openssl s_client -connect 127.0.0.1:포트 -servername 이름 2>/dev/null | openssl x509 -noout -subject (the placeholders are the port and the name).

Accept plain text and TLS together on one port

Add a chain plain that matches transport_protocol: "raw_buffer" to the mixed listener, at the very front, and make it return chain=plain. Send a plain HTTP request and a TLS request to the same port 10443 and write the results to /root/envd-listener/05-raw.txt as two lines, plain= and tls=.

tls_inspector looks at the first bytes, decides whether this connection is TLS or not, and sets transport_protocol to tls or raw_buffer. That is why the same port can accept both. A plain-text request is curl http://127.0.0.1:포트/ (the placeholder is the port), and the TLS request is done the same way as in the previous step. The chain order does not affect matching — the specificity of the conditions decides.

When both conditions match, which one wins

Add a fourth chain alpn-h2 to mixed — it matches only transport_protocol: "tls" and application_protocols: ["h2"], and returns chain=alpn-h2 over HTTP/2. Then request with the SNI set to a.envd.test, over HTTP/2 and check which chain accepts it, and request with the SNI set to z.envd.test, over HTTP/2 and check which chain accepts it, and write them to /root/envd-listener/06-alpn.txt as two lines, sni_and_h2= and h2_only=.

Filter chain matching narrows through eight steps in a fixed order — destination port, destination IP, server name (SNI), transport protocol, application protocol (ALPN), then the source conditions. The key point of this step is that SNI comes before ALPN. See for yourself what remains when both conditions match at the same time. Use curl --http2 -k --resolve ... to make the ALPN h2.

If no chain matches, you get silence, not a response

Send a HTTP/1.1 TLS request to z.envd.test — it matches no chain. In /root/envd-listener/07-nomatch.txt, write two lines: nomatch_rc= (the curl exit code of that request) and match_rc= (the exit code of the same request sent to a.envd.test).

If no chain matches, Envoy does not give a 404 — because it never got as far as the HTTP layer. It just closes the connection. On the client side it looks like a TLS handshake failure, and curl returns a nonzero exit code. A big share of the reports that come in as "I added the certificate but the connection is just cut" in production is this. Take the exit code of curl from $?.

Leave a chain design memo

In /root/envd-listener/08-report.md, write four lines chains= (the number of chains of the mixed listener), same_port_plain_and_tls= (yes or no), sni_beats_alpn= (the name of the chain that won when SNI and ALPN both matched in step 6) and nomatch_rc=, and below them write what you learned in at least four lines.

This memo is something you will read yourself the next time you design a gateway. Do not write only values; write "so what I will be careful about". For the number of chains, do not count by eye; it is more accurate to pull out the length of filter_chains with yq.