Prepare a bounded experiment
Save the complete code below as run.py. You need Python 3.13, OpenSSL 3.6 and NGINX with the HTTP SSL module. Set DR_NGINX_BIN and DR_OPENSSL_BIN to executable paths and run python3 run.py --output evidence.json. The default client is /usr/bin/curl; DR_CURL_BIN can select another executable whose results may differ. The script creates two disposable authorities and certificates in a temporary directory without installing them in system trust. It uses only loopback and locally resolved.test names. Predict HTTP status, client exit and origin request count before execution.
Separate trust failure from name failure
The first direct request does not supply the origin synthetic CA. In this review record, curl exits with 60, http_code is 000 and the origin receives no HTTP. The request with the correct CA and origin.fund.test name reaches 200. The same trust is then used with wrong.fund.test and verification fails again. Exit 60 is an error class, not a complete diagnosis. Compare detail, chain and expected name before requesting renewal. In this exercise, renewing without correcting the identified condition can repeat the same failure with a different certificate.
Preserve the name during migration
The --resolve option associates a name and port pair with the experiment local address. The URL still identifies origin.fund.test; evidence records that name in SNI and the received Host. In a fictional middleware migration, this separation allows testing the destination before changing shared DNS. Do not describe the result as proof of DNS propagation: that step was overridden in the client. Retain address, port, URL name and trust configuration. A colleague can then repeat the test without guessing which part of the path changed to reach the new server.
Host does not replace TLS identity
In the group using a 127.0.0.1 URL, adding Host: origin.fund.test does not make verification pass. This experiment certificate contains only the stated DNS identity, without a SAN for that IP. Another group retains the correct URL and SNI but changes Host to wrong.fund.test. TLS is accepted and the synthetic application returns 421. That status is a fixture rule, not a prediction for every server. The comparison demonstrates that verifying the TLS peer and selecting the HTTP application are separate steps. An operational test must confirm both when routing depends on names.
Read evidence without exposing private material
The evidence.json file records request arguments, exits, statuses, synthetic bodies, received names and negotiated versions. It does not contain private-key contents. The code creates fresh material on every run and removes the temporary directory at the end; confirm temporaryPrivateMaterialDeleted and childExited. For handover, use stable outcomes and artifact hashes. Ports, dates and certificates can change between runs. If an experiment fails, investigate the group and specific client version. Do not change personal trust merely to turn green an exercise designed to use explicit disposable trust.
Know versions and boundaries
This execution uses NGINX 1.30.5 with OpenSSL 3.6.1, Python 3.13.1 and curl 8.7.1. Logs recorded TLSv1.3 on successful connections, but that does not validate every version permitted by configuration. Consulted Python documentation belongs to the 3.13 line and identifies 3.13.16; that was not the executed runtime. There was no browser, mTLS, OCSP, CRL, expiry experiment or renewal deployment. The TLS and Certificates course contains a complementary offline lab. Finish this lesson with three separate failure hypotheses: trust, name and HTTP selection, identifying the observation that would distinguish each.
"""Original DR two-hop HTTPS lab. Synthetic certificates and loopback only.
DR_NGINX_BIN=/path/to/nginx DR_OPENSSL_BIN=/path/to/openssl python3 run.py --output evidence.json
No system trust-store edits; all generated keys are discarded with the temp directory.
"""
import argparse, hashlib, http.server, json, os, pathlib, platform, shutil
import signal, socket, ssl, subprocess, tempfile, threading, time
from datetime import datetime, timezone
def run(nginx, openssl, curl):
checks, commands, events, sni_events = {}, [], [], []
process, origin, thread = None, None, None
def check(name, details, valid):
checks[name] = {'passed':bool(valid), **details}
if not valid:
raise AssertionError(name + ': ' + json.dumps(details))
with tempfile.TemporaryDirectory(prefix='dr-http-tls-') as tmp:
root=pathlib.Path(tmp)
def crypto(*args):
r=subprocess.run([openssl,*args],cwd=root,capture_output=True,text=True,timeout=15)
if r.returncode:
raise RuntimeError('OpenSSL certificate setup failed: '+r.stderr)
def ca(name):
crypto('req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256','-noenc','-keyout',name+'.key','-out',name+'.pem','-days','2','-subj','/CN=DR synthetic '+name,'-addext','basicConstraints=critical,CA:TRUE','-addext','keyUsage=critical,keyCertSign,cRLSign')
def leaf(name,host,issuer):
crypto('req','-new','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256','-noenc','-keyout',name+'.key','-out',name+'.csr','-subj','/CN='+host)
(root/(name+'.ext')).write_text('basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:'+host+'\n')
crypto('x509','-req','-in',name+'.csr','-CA',issuer+'.pem','-CAkey',issuer+'.key','-CAcreateserial','-out',name+'.pem','-days','1','-extfile',name+'.ext')
ca('front-ca');ca('origin-ca');leaf('front','portal.fund.test','front-ca');leaf('origin','origin.fund.test','origin-ca')
class Origin(http.server.BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def do_GET(self):
host=self.headers.get('Host','').split(':')[0]
events.append({'path':self.path,'host':host,'sni':getattr(self.connection,'lab_sni',None),'tlsVersion':self.connection.version})
status,body,kind=200,'{"ready":true,"version":"synthetic-v1"}','application/json'
if host!='origin.fund.test':status,body,kind=421,'synthetic wrong HTTP host','text/plain'
elif self.path=='/login':body,kind='<html><body>Synthetic login page</body></html>','text/html'
data=body.encode;self.send_response(status);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(data)));self.send_header('Connection','close');self.end_headers;self.wfile.write(data);self.close_connection=True
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.minimum_version=ssl.TLSVersion.TLSv1_2
context.load_cert_chain(root/'origin.pem',root/'origin.key')
def sni(sock,name,ctx):
sni_events.append(name);sock.lab_sni=name
context.sni_callback=sni
origin=http.server.ThreadingHTTPServer(('127.0.0.1',0),Origin);origin.daemon_threads=True
origin.socket=context.wrap_socket(origin.socket,server_side=True)
thread=threading.Thread(target=origin.serve_forever,daemon=True);thread.start
with socket.socket as s:s.bind(('127.0.0.1',0));port=s.getsockname[1]
config='''daemon off;
worker_processes 1;
error_log "ROOT/error.log" info;
pid "ROOT/nginx.pid"
events { worker_connections 64; }
http {
log_format evidence escape=json '{"path":"$request_uri","status":"$status","upstream":"$upstream_status","tls":"$ssl_protocol","sni":"$ssl_server_name"}'
access_log "ROOT/access.log" evidence;
proxy_temp_path "ROOT/proxy-temp"
server {
listen 127.0.0.1:PORT ssl;
server_name portal.fund.test;
ssl_certificate "ROOT/front.pem"
ssl_certificate_key "ROOT/front.key"
ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate "ROOT/origin-ca.pem"
proxy_ssl_name origin.fund.test;
proxy_ssl_server_name on;
proxy_ssl_session_reuse off;
proxy_set_header Host origin.fund.test;
location / { proxy_pass https://127.0.0.1:ORIGIN; }
location = /wrong-name {
proxy_ssl_name wrong.fund.test;
proxy_pass https://127.0.0.1:ORIGIN;
}
location = /wrong-trust {
proxy_ssl_trusted_certificate "ROOT/front-ca.pem"
proxy_pass https://127.0.0.1:ORIGIN;
}
# Deliberately unsafe negative control, only on this disposable loopback fixture.
location = /negative-control {
proxy_ssl_verify off;
proxy_ssl_name wrong.fund.test;
proxy_pass https://127.0.0.1:ORIGIN;
}
location = /no-sni {
proxy_ssl_server_name off;
proxy_pass https://127.0.0.1:ORIGIN;
}
}
}
'''.replace('ROOT',str(root)).replace('PORT',str(port)).replace('ORIGIN',str(origin.server_port))
conf=root/'nginx.conf'conf.write_text(config)
try:
validation=subprocess.run([nginx,'-t','-p',str(root)+'/', '-c',str(conf)],capture_output=True,text=True,timeout=10)
if validation.returncode:raise RuntimeError(validation.stderr)
with open(root/'process.log','w') as log:
process=subprocess.Popen([nginx,'-p',str(root)+'/', '-c',str(conf)],stdout=log,stderr=log)
for _ in range(100):
if process.poll is not None:raise RuntimeError((root/'process.log').read_text)
try:
with socket.create_connection(('127.0.0.1',port),timeout=.2):break
except OSError:time.sleep(.05)
else:raise TimeoutError('NGINX readiness timeout')
def request(label,host,listen,path='/',trust=None,extra=):
target=f'https://{host}:{listen}{path}'
args=[curl,'-q','--noproxy','*','--http1.1','--silent','--show-error','--connect-timeout','3','--max-time','5','--resolve',f'{host}:{listen}:127.0.0.1','--output',str(root/'body'),'--write-out','%{http_code}|%{ssl_verify_result}|%{content_type}']
if trust:args+=['--cacert',str(root/(trust+'.pem'))]
args+=list(extra)+[target]
env={k:v for k,v in os.environ.items if k not in ['CURL_CA_BUNDLE','SSL_CERT_FILE','SSL_CERT_DIR','SSLKEYLOGFILE']}
(root/'body').write_text('')
r=subprocess.run(args,capture_output=True,text=True,timeout=10,env=env)
fields=r.stdout.split('|');row={'label':label,'args':args,'exit':r.returncode,'status':int(fields[0] or '0'),'verifyResult':fields[1] if len(fields)>1 else '', 'contentType':fields[2] if len(fields)>2 else '', 'body':(root/'body').read_text,'stderr':r.stderr}
commands.append(row);return row
before=len(events);r=request('untrusted-origin','origin.fund.test',origin.server_port)
check('untrusted-origin-stops-before-http',{'curlExit':r['exit'],'httpStatus':r['status'],'originHttpRequests':len(events)-before},r['exit']==60 and r['status']==0 and len(events)==before)
r=request('trusted-origin','origin.fund.test',origin.server_port,trust='origin-ca')
check('trusted-name-and-resolve-reach-origin',{'curlExit':r['exit'],'httpStatus':r['status'],'sni':events[-1]['sni'],'host':events[-1]['host']},r['exit']==0 and r['status']==200 and events[-1]['sni']=='origin.fund.test' and events[-1]['host']=='origin.fund.test')
before=len(events);r=request('wrong-reference-name','wrong.fund.test',origin.server_port,trust='origin-ca')
check('trusted-chain-does-not-accept-wrong-name',{'curlExit':r['exit'],'httpStatus':r['status'],'originHttpRequests':len(events)-before},r['exit']==60 and r['status']==0 and len(events)==before)
before=len(events);r=request('ip-with-host-header','127.0.0.1',origin.server_port,trust='origin-ca',extra=['-H','Host: origin.fund.test'])
check('host-header-does-not-replace-tls-reference',{'curlExit':r['exit'],'httpStatus':r['status'],'originHttpRequests':len(events)-before},r['exit']==60 and r['status']==0 and len(events)==before)
r=request('valid-tls-wrong-http-host','origin.fund.test',origin.server_port,trust='origin-ca',extra=['-H','Host: wrong.fund.test'])
check('valid-tls-can-reach-wrong-http-service',{'curlExit':r['exit'],'httpStatus':r['status'],'sni':events[-1]['sni'],'host':events[-1]['host']},r['exit']==0 and r['status']==421 and events[-1]['sni']=='origin.fund.test' and events[-1]['host']=='wrong.fund.test')
r=request('two-verified-hops','portal.fund.test',port,trust='front-ca')
check('separate-trust-and-name-on-two-hops',{'curlExit':r['exit'],'httpStatus':r['status'],'upstreamSni':events[-1]['sni'],'upstreamHost':events[-1]['host']},r['exit']==0 and r['status']==200 and events[-1]['sni']=='origin.fund.test')
before=len(events);wrong=request('proxy-wrong-name','portal.fund.test',port,'/wrong-name','front-ca')
check('upstream-name-failure-becomes-http-502',{'curlExit':wrong['exit'],'httpStatus':wrong['status'],'originHttpRequests':len(events)-before},wrong['exit']==0 and wrong['status']==502 and len(events)==before)
before=len(events);r=request('proxy-wrong-trust','portal.fund.test',port,'/wrong-trust','front-ca')
check('front-trust-does-not-establish-upstream-trust',{'curlExit':r['exit'],'httpStatus':r['status'],'originHttpRequests':len(events)-before},r['exit']==0 and r['status']==502 and len(events)==before)
r=request('unsafe-negative-control','portal.fund.test',port,'/negative-control','front-ca')
check('disabled-verification-masks-upstream-name-failure',{'curlExit':r['exit'],'httpStatus':r['status'],'upstreamSni':events[-1]['sni'],'verificationEnabled':False,'acceptedConfiguration':False},r['exit']==0 and r['status']==200 and events[-1]['sni']=='wrong.fund.test')
r=request('fail-with-body-502','portal.fund.test',port,'/wrong-name','front-ca',['--fail-with-body'])
check('curl-http-policy-is-distinct-from-front-tls',{'plainExit':wrong['exit'],'failWithBodyExit':r['exit'],'httpStatus':r['status'],'bodyRetained':bool(r['body'])},wrong['exit']==0 and r['exit']==22 and r['status']==502 and bool(r['body']))
r=request('verification-without-sni','portal.fund.test',port,'/no-sni','front-ca')
check('sni-and-verification-are-separate-controls',{'curlExit':r['exit'],'httpStatus':r['status'],'upstreamSni':events[-1]['sni'],'verificationEnabled':True},r['exit']==0 and r['status']==200 and events[-1]['sni'] is None)
r=request('valid-tls-wrong-application-body','portal.fund.test',port,'/login','front-ca')
check('https-200-does-not-prove-functional-readiness',{'curlExit':r['exit'],'httpStatus':r['status'],'contentType':r['contentType'],'expectedJsonReceived':r['body'].startswith('{'),'loginPageReceived':'Synthetic login page' in r['body']},r['exit']==0 and r['status']==200 and r['contentType']=='text/html' and 'Synthetic login page' in r['body'])
process.send_signal(signal.SIGQUIT);process.wait(timeout=10)
access=[json.loads(x) for x in (root/'access.log').read_text.splitlines if x.strip]
error=(root/'error.log').read_text
assert 'upstream SSL certificate does not match' in error
assert 'upstream SSL certificate verify error' in error
result={'executedAt':datetime.now(timezone.utc).isoformat,'nginxVersion':subprocess.run([nginx,'-V'],capture_output=True,text=True,check=True).stderr.strip,'opensslVersion':subprocess.run([openssl,'version'],capture_output=True,text=True,check=True).stdout.strip,'pythonVersion':platform.python_version,'pythonSslVersion':ssl.OPENSSL_VERSION,'curlVersion':subprocess.run([curl,'--version'],capture_output=True,text=True,check=True).stdout.splitlines[0],'checks':checks,'passed':sum(x['passed'] for x in checks.values),'failed':sum(not x['passed'] for x in checks.values),'commands':commands,'originEvents':events,'originSniEvents':sni_events,'accessLog':access,'errorLog':error,'configuration':config,'scriptSha256':hashlib.sha256(pathlib.Path(__file__).read_bytes).hexdigest,'binarySha256':hashlib.sha256(pathlib.Path(nginx).read_bytes).hexdigest,'scope':'Actual curl, NGINX and Python HTTPS on IPv4 loopback with disposable EC certificates and two separate synthetic CAs. No personal trust-store changes, real credentials, external endpoints, browser, client certificates, revocation checks, expiry test, renewal deployment, load test or production. The disabled-verification route is an explicitly rejected negative control, not a mitigation.'}
finally:
if process is not None and process.poll is None:
process.send_signal(signal.SIGQUIT)
try:process.wait(timeout=10)
except subprocess.TimeoutExpired:process.kill;process.wait(timeout=5)
origin.shutdown;origin.server_close;thread.join(timeout=5)
result['temporaryPrivateMaterialDeleted']=not root.exists;result['childExited']=process.returncode is not None
return result
if __name__=='__main__':
p=argparse.ArgumentParser;p.add_argument('--output',required=True);args=p.parse_args
nginx=os.environ.get('DR_NGINX_BIN');openssl=os.environ.get('DR_OPENSSL_BIN') or shutil.which('openssl');curl=os.environ.get('DR_CURL_BIN','/usr/bin/curl')
if not nginx or not pathlib.Path(nginx).is_file:p.error('DR_NGINX_BIN must name a TLS-enabled NGINX executable')
if not openssl:p.error('OpenSSL executable required')
result=run(str(pathlib.Path(nginx).resolve),openssl,curl);pathlib.Path(args.output).write_text(json.dumps(result,indent=2)+'\n');print(json.dumps({'passed':result['passed'],'failed':result['failed'],'temporaryPrivateMaterialDeleted':result['temporaryPrivateMaterialDeleted'],'childExited':result['childExited']}))
In a test before DNS migration, an IP URL fails despite a correct Host; the named URL with --resolve succeeds using explicit trust.
Common pitfalls
Requesting renewal solely on exit 60, using Host as TLS identity or assuming --resolve demonstrated public DNS behavior.
Related topics: The request and intended representation · HTTPS, identity, and proxy hops · Diagnosis and time budgets
Useful diagnosis identifies the failed condition and the client that checked it, preserving the intended service name and trust.
Reference: curl TLS certificate verification · BigSavant HTTP/HTTPS 2026-09; HTTP RFCs 9110–9114; selected TLS 1.3 and NGINX/curl guidance