Faking SNI with curl and OpenSSL: Testing HAProxy Routing Without Touching DNS
How to override the TLS SNI value with curl's --connect-to and openssl s_client's -servername, why -H Host doesn't work for this, and how to set up the self-signed certs to make it verify cleanly.
I was building an SNI-based router in front of HAProxy: one load balancer,
one public hostname, and a map file that picks a backend based on which
domain name the client asked for during the TLS handshake. To test that
without registering ten domains and pointing DNS at a box I was still
iterating on, I needed a way to say "connect to this IP, but pretend you're
asking for server1.com." curl and OpenSSL both do this, but the flag isn't
the one people usually reach for first.
The mistake: -H "Host: ..."
The natural instinct is to rewrite the Host header:
curl -H "Host: server1.com" https://1.2.3.4/That works for plain HTTP virtual hosting, and it does nothing for
SNI-based routing. SNI is negotiated during the TLS handshake, before a
single byte of HTTP exists. The client has to say which hostname it wants
while opening the TLS connection, so the server can pick the right
certificate and, in this case, the right backend. The Host header lives
inside the encrypted HTTP request that comes after the handshake is already
done. Rewriting it doesn't touch SNI at all — HAProxy's req.ssl_sni never
sees it.
curl --connect-to
The flag that actually does this is --connect-to:
curl --cacert ca.crt \
--connect-to server1.com:443:ec2-3-140-191-204.us-east-2.compute.amazonaws.com:443 \
https://server1.comRead it as HOST1:PORT1:HOST2:PORT2: whenever curl would connect to
HOST1:PORT1, redirect the actual TCP connection to HOST2:PORT2 instead.
Everything else — the SNI value sent in the TLS ClientHello, the Host
header, the certificate hostname check — still uses the original URL,
server1.com. You get a real TCP connection to the load balancer's real
address, carrying a fake destination name that only exists at the TLS and
HTTP layers. That's exactly what SNI-based routing needs to see to make its
decision, and exactly what DNS would normally provide if server1.com were
a real record pointing at that box.
A few flags worth knowing alongside it:
--resolve HOST:PORT:IPis the simpler sibling — it maps a hostname straight to an IP without letting you name a second hostname. Use it when you just need to skip DNS; use--connect-towhen you need the request to look like it's headed at one host while the TLS/HTTP identity is another.--cacert ca.crtpoints curl at the CA that signed the server's certificate, so it can verify the chain instead of refusing to connect.-kskips verification entirely. Fine for a five-second sanity check, useless for actually confirming the routing and the cert are both correct at once — which is usually the point.
Two backends, two fake hostnames, one real address:
curl --cacert ca.crt --connect-to server1.com:443:$LB_HOST:443 https://server1.com
curl --cacert ca.crt --connect-to server2.com:443:$LB_HOST:443 https://server2.comIf HAProxy's map routes server1.com and server2.com to different
backends, these two commands should come back with different responses,
with zero DNS records involved.
OpenSSL's s_client -servername
openssl s_client gives you the same override, but at a lower level —
useful when you want to see the raw handshake instead of a completed HTTP
response, or when curl's abstraction is hiding something you need to
inspect directly.
openssl s_client -connect ec2-3-140-191-204.us-east-2.compute.amazonaws.com:443 \
-CAfile lb_ca.crt \
-verify_return_error \
-servername server1.com-connect is the real address, same as --connect-to's second half.
-servername is the SNI value, same as --connect-to's first half.
-CAfile plus -verify_return_error does what --cacert does for curl,
except it actually fails loudly and exits nonzero on a bad chain instead of
just printing a warning and continuing — worth adding every time, since
s_client without it will happily complete a handshake against a cert it
doesn't trust and let you miss that.
Once connected, s_client drops you into a raw socket where you can type
an HTTP request by hand:
GET / HTTP/1.1
Host: server1.com
Note the Host header here is independent of -servername — you can set
them to different values on purpose if you're debugging a proxy that makes
routing decisions on one and passes the other straight through to the
backend, which is a good way to catch bugs where those two are assumed to
always match.
Setting up certs that actually verify
None of this is useful if you're leaning on -k to route around cert
errors, because then you can't tell "routing is wrong" apart from "cert is
wrong." A minimal self-signed CA plus a leaf cert takes four commands.
Root CA first — this is the thing you'll point --cacert / -CAfile at:
openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt -subj "/CN=my-test-ca"Then the server key and a CSR for it:
openssl genrsa -out instance.key 2048
openssl req -new -key instance.key -out instance.csr -subj "/CN=$(hostname)"Sign the CSR with the CA to get the leaf certificate:
openssl x509 -req -in instance.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out instance.crt -days 1825 -sha256One thing that will bite you if you skip it: modern curl and OpenSSL verify
the Subject Alternative Name, not the legacy Common Name field. A cert
built with only -subj "/CN=..." and no SAN extension will fail
verification against a real hostname even though the CN looks right. Add it
explicitly, either as a self-signed one-liner:
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout instance.key -out instance.crt \
-subj "/CN=$(hostname)" \
-addext "subjectAltName=DNS:$(hostname)"or via a config file if you need multiple SANs for multiple test hostnames.
Since --connect-to and -servername both send whatever hostname you
choose during verification, that hostname has to actually be present in the
cert's SAN list or the handshake fails — which is also a handy way to
confirm the override is real and not just being silently ignored somewhere.
Finally, concatenate cert and key for HAProxy, which expects both in one
PEM file for its bind ... crt directive:
cat instance.crt instance.key > instance_ha.pemPutting it together
With the CA and leaf cert in place and HAProxy bound to instance_ha.pem
with an SNI-based backend map, the whole loop for testing a new routing
rule becomes: add an entry to the map (by hand, or via the Data Plane
API if HAProxy is
already running), then hit it with a fake hostname that never touched DNS:
curl -sX POST --user admin:adminpwd \
-H "Content-Type: application/json" \
-d '{"key": "server3.com", "value": "be_server_3"}' \
"http://localhost:5555/v2/services/haproxy/runtime/maps_entries?map=sni.map&force_sync=true"
curl --cacert ca.crt --connect-to server3.com:443:$LB_HOST:443 https://server3.comNo DNS record, no waiting on propagation, no separate test environment per hostname. Just a CA you trust, a SAN that matches what you're about to claim, and a curl or openssl flag that lets you lie about the hostname at exactly the layer where the routing decision actually gets made.