DNS records and HTTP routing answer different questions
Trace an alias to a local HTTPS server, observe TLS SNI and HTTP Host separately, and distinguish a certificate rejection from an application routing failure.

A hostname resolving to the intended IP address does not prove that an HTTPS request will reach the intended website. The client must also establish a connection, accept the server’s certificate for the requested name and send an HTTP request that the server routes correctly. Several websites can share one address while having different names and application behavior.
This article is for a developer beginning to debug web infrastructure. You need basic terminal and Python knowledge. We will use Python’s standard library, curl and OpenSSL to start an HTTPS server on the loopback interface. The experiment was checked with Python 3.12, curl 8.5.0 and OpenSSL 3.0.13. It changes no machine-wide resolver file, public DNS record or existing service.
Track the original name through the request
Consider the declared records shop.lab.test CNAME origin.lab.test and origin.lab.test A 127.0.0.1. The Cloudflare DNS reference describes A and AAAA as mappings to IPv4 and IPv6 addresses, and CNAME as a mapping to another canonical name. Following this alias can discover an address for the requested hostname. It does not instruct an HTTP client to replace its URL with the canonical name.
Our tiny record dictionary models that chain. It is not a DNS server, packet capture or test of a recursive resolver. curl’s --resolve then supplies the already selected address for an exact name-and-port pair. This keeps the demonstration local while preserving the name in the URL. Real DNS involves additional behavior such as caching, multiple answers and failures; the dictionary does not implement it.
The address answers where to attempt a connection. For this experiment, the HTTPS URL also supplies the port. During TLS setup, curl sends the URL’s hostname as Server Name Indication, or SNI, and checks the certificate against that requested name. SNI lets a server distinguish names sharing an address; it is not an application authorization credential. RFC 6066 defines the server-name extension and describes its possible use in certificate selection.
After TLS succeeds, an HTTP/1.1 request carries a Host field containing the target host and, here, the explicit port. The server can use that authority to select a website. RFC 9110 describes this application routing role and notes the related :authority field in HTTP/2 and HTTP/3. The fixture forces HTTP/1.1 so that its observed header is unambiguous.
Observe the names with a local HTTPS server
Save the program below as routing_lab.py and run python3 -I routing_lab.py. It generates a one-day self-signed certificate naming both test hosts and trusts that certificate only for the individual curl requests. The key and certificate live in the temporary directory. They are not installed in a system trust store.
The server binds only to 127.0.0.1 and asks the operating system for an available port. Its TLS callback records the supplied SNI, while its HTTP handler records Host and chooses one of two literal routes. The handler is deliberately small: its route lookup matches only the exact ASCII names shown. It is not a production hostname parser or authorization layer.
from pathlib import Path
from tempfile import TemporaryDirectory
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json, os, ssl, subprocess, threading
RECORDS = {'shop.lab.test': ('CNAME', 'origin.lab.test'),
'origin.lab.test': ('A', '127.0.0.1')}
def fixture_address(name):
seen = set()
while True:
if name in seen:
raise ValueError('CNAME loop in declared fixture')
seen.add(name)
kind, value = RECORDS[name]
if kind == 'A':
return value
if kind != 'CNAME':
raise ValueError('Unsupported fixture record type')
name = value
# This dictionary is a declared record fixture, not a DNS server or query.
assert fixture_address('shop.lab.test') == '127.0.0.1'
env = {'PATH': os.environ['PATH'], 'LANG': 'C.UTF-8'}
requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
host = self.headers.get('Host', '')
name = host.partition(':')[0]
routes = {'shop.lab.test': 'storefront', 'origin.lab.test': 'origin-route'}
status = 200 if name in routes else 404
body = {'sni': self.connection.requested_name, 'host': host,
'route': routes.get(name, 'no-route')}
requests.append(body)
encoded = json.dumps(body).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, *args):
pass
with TemporaryDirectory(prefix='routing-lab-') as folder:
root = Path(folder)
cert, key = root / 'cert.pem', root / 'key.pem'
made = subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-days', '1', '-subj', '/CN=shop.lab.test',
'-addext', 'subjectAltName=DNS:shop.lab.test,DNS:origin.lab.test',
'-keyout', str(key), '-out', str(cert)], env=env,
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15)
assert made.returncode == 0, made.stderr
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
def remember_sni(connection, name, initial_context):
connection.requested_name = name
context.set_servername_callback(remember_sni)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
server.socket = context.wrap_socket(server.socket, server_side=True)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def fetch(name, host_override=None):
args = ['curl', '-q', '--silent', '--show-error', '--http1.1',
'--noproxy', '*', '--connect-timeout', '2', '--max-time', '5',
'--cacert', str(cert), '--resolve',
f'{name}:{port}:127.0.0.1']
if host_override is not None:
args += ['--header', 'Host: ' + host_override]
args += ['--write-out', '\n%{http_code}', f'https://{name}:{port}/']
return subprocess.run(args, env=env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=8)
try:
cases = [('shop.lab.test', None, 'storefront', 200),
('origin.lab.test', None, 'origin-route', 200),
('shop.lab.test', 'origin.lab.test', 'origin-route', 200),
('shop.lab.test', 'missing.lab.test', 'no-route', 404)]
for name, override, route, expected_status in cases:
response = fetch(name, override)
assert response.returncode == 0, response.stderr
payload, status = response.stdout.rsplit('\n', 1)
observed = json.loads(payload)
assert observed['sni'] == name and observed['route'] == route
assert observed['host'] == (override or f'{name}:{port}')
assert int(status) == expected_status
print(name, 'Host=' + observed['host'].replace(':' + str(port), ':PORT'),
'SNI=' + observed['sni'], status, route)
before = len(requests)
rejected = fetch('wrong.lab.test', 'shop.lab.test')
assert rejected.returncode == 60 and len(requests) == before
print('wrong.lab.test: certificate name rejected before HTTP; curl exit 60')
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
assert not thread.is_alive()
The port is replaced with PORT in printed output so that reruns remain comparable:
shop.lab.test Host=shop.lab.test:PORT SNI=shop.lab.test 200 storefront
origin.lab.test Host=origin.lab.test:PORT SNI=origin.lab.test 200 origin-route
shop.lab.test Host=origin.lab.test SNI=shop.lab.test 200 origin-route
shop.lab.test Host=missing.lab.test SNI=shop.lab.test 404 no-route
wrong.lab.test: certificate name rejected before HTTP; curl exit 60
The first two requests reach the same listening socket but retain different names. The alias URL gets the storefront route; using the origin name in the URL gets the origin route. Both names are covered by the temporary certificate, so either can pass this fixture’s identity check. Sharing an IP address has not made their HTTP authorities interchangeable.
The program disables curl’s default configuration file with its first -q option, bypasses proxies and uses a limited subprocess environment. Connection and overall timeouts bound the client calls. A finally block shuts down the owned server and closes its socket. These choices make the observed traffic and cleanup understandable without depending on the developer’s personal curl configuration.
Changing Host does not change curl’s TLS name
The third request keeps https://shop.lab.test:PORT/ as the URL but supplies Host: origin.lab.test. The observed SNI remains shop, while the custom handler selects its origin route. This is an intentional mismatch used to inspect the two inputs separately. A real reverse proxy may reject such a mismatch or apply its own routing rules; this one server’s acceptance is not a universal interoperability promise.
The everything curl chapter explains why a custom Host header alone is insufficient for testing HTTPS by name: curl derives SNI from the URL. It also explains that --resolve changes curl’s address selection while retaining the URL name for SNI and certificate checking. That is the useful operation when checking a named site at a particular address.
Replacing the URL hostname with an IP address and adding a Host header is therefore a different experiment. It changes the identity curl is asked to verify during TLS. Disabling certificate verification might let such a request continue, but removes exactly the name check that this test is intended to preserve. Prefer keeping the intended name in the URL and supplying the address explicitly.
The fourth request demonstrates another distinction: TLS succeeds for shop, but Host names a route absent from the handler’s table. The response is HTTP 404 with the recorded names and no-route. curl exits successfully because this command does not ask curl to treat HTTP error statuses as process failures. The code checks both the process result and the HTTP status rather than equating a zero process exit with a successful application route.
Diagnose the layer before changing its configuration
The final request asks for wrong.lab.test while supplying a shop Host header. The certificate does not cover the URL name, so curl exits with code 60 before sending an HTTP request. The server’s HTTP request count remains unchanged. A correct Host value cannot repair a certificate mismatch that prevents the HTTP layer from being reached.
The numeric exit code alone is not a complete diagnosis: curl can use code 60 for other certificate verification failures too. Keep its diagnostic and inspect which trust or name condition failed. In this controlled case, the same trusted certificate passes for shop and origin, and only the URL name is changed to a name absent from its subject alternative names.
In a real investigation, record the URL, the selected address and port, the certificate result, the HTTP authority and the response. If no address is available, inspect resolution. If the connection is refused, inspect the listener and path to it. If certificate verification fails, inspect trust and name coverage before trying application routes. If HTTP reaches an unintended website, inspect the authority-to-route configuration. These observations locate the next useful check; they do not by themselves identify the person or deployment that caused the problem.
A CNAME edit cannot add a missing certificate name or create an application route. An HTTP redirect to another URL is also different from a DNS alias: it is an HTTP response that asks the client to make another request to a new location. The initial request must already reach an HTTP server to receive that response. A bare CNAME supplies no redirect status or URL path.
For an exercise, remove origin.lab.test from the generated certificate’s name list. The direct origin URL should then fail identity verification, while the shop URL with an overridden origin Host still reaches this permissive handler. Next restore the certificate and remove the origin entry from the route table: TLS should pass but the route should return 404. Keep each change separate so the evidence explains which contract you altered.
Finally, change the declared alias target to a missing key or create a cycle. The small dictionary resolver should fail before its address assertion. This tests the fixture’s boundary, not a DNS resolver’s specific error code. Real resolver behavior needs a separate query trace. The useful habit is to preserve the requested identity while independently checking address selection, TLS verification and HTTP routing.