Asif Iqbal_
← Blog

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.com

Read 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:IP is 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-to when you need the request to look like it's headed at one host while the TLS/HTTP identity is another.
  • --cacert ca.crt points curl at the CA that signed the server's certificate, so it can verify the chain instead of refusing to connect.
  • -k skips 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.com

If 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 -sha256

One 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.pem

Putting 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.com

No 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.