Preparar um ensaio delimitado
Guarda o código completo abaixo como run.py. Precisas de Python 3.13, OpenSSL 3.6 e NGINX com o módulo HTTP SSL. Define DR_NGINX_BIN e DR_OPENSSL_BIN com os caminhos dos executáveis e executa python3 run.py --output evidence.json. O cliente predefinido é /usr/bin/curl; DR_CURL_BIN permite indicar outro executável, cujos resultados podem diferir. O script gera duas autoridades e certificados descartáveis numa pasta temporária, sem os instalar no trust store do sistema. Usa apenas loopback e nomes .test resolvidos localmente. Prevê o estado HTTP, a saída do cliente e o número de pedidos à origem antes de executar.
Separar a falha de confiança da falha de nome
O primeiro pedido direto não fornece a CA sintética da origem. No registo desta revisão, curl termina com 60, http_code é 000 e a origem não recebe HTTP. O pedido com a CA correta e nome origin.fund.test chega a 200. Depois, a mesma confiança é usada com wrong.fund.test e a validação volta a falhar. A saída 60 é uma classe de erro, não um diagnóstico completo. Compara o detalhe, a cadeia e o nome esperado antes de pedir uma renovação. Neste exercício, renovar sem corrigir a condição identificada pode repetir a mesma falha com outro certificado.
Preservar o nome durante uma migração
A opção --resolve associa um par nome e porta ao endereço local do ensaio. O URL continua a identificar origin.fund.test; a evidência regista esse nome em SNI e no Host recebido. Num caso fictício de migração de middleware, esta separação permite testar o destino antes de alterar o DNS partilhado. Não descrevas o resultado como prova de propagação DNS: essa etapa foi sobreposta no cliente. Guarda endereço, porta, nome do URL e confiança usada. Assim, um colega consegue repetir o teste sem adivinhar qual parte do percurso foi alterada para chegar ao novo servidor.
Host não substitui identidade TLS
No grupo que usa um URL com 127.0.0.1, acrescentar Host: origin.fund.test não faz a validação passar. O certificado deste ensaio tem apenas a identidade DNS indicada, sem SAN para esse IP. Noutro grupo, o URL e SNI corretos são mantidos, mas Host passa a wrong.fund.test. TLS é aceite e a aplicação sintética devolve 421. Esse código é uma regra da fixture, não uma previsão para todos os servidores. A comparação demonstra que validar o par TLS e selecionar a aplicação HTTP são etapas distintas. Um teste operacional precisa de confirmar ambas quando o encaminhamento depende de nomes.
Ler a evidência sem expor material privado
O ficheiro evidence.json regista argumentos dos pedidos, saídas, estados, corpos sintéticos, nomes recebidos e versões negociadas. Não contém o conteúdo das chaves privadas. O código cria material novo em cada execução e elimina a pasta temporária no fim; confirma temporaryPrivateMaterialDeleted e childExited. Para o handover, usa os resultados estáveis e os hashes dos artefactos. Portas, datas e certificados podem mudar entre execuções. Se um ensaio falhar, investiga o grupo e a versão concreta do cliente. Não alteres o repositório de confiança pessoal para tornar verde um exercício que foi desenhado para usar confiança explícita e descartável.
Conhecer as versões e os limites
Esta execução usa NGINX 1.30.5 com OpenSSL 3.6.1, Python 3.13.1 e curl 8.7.1. Os logs registaram TLSv1.3 nas ligações bem-sucedidas, mas isso não valida todas as versões permitidas na configuração. A documentação Python consultada corresponde à linha 3.13 e identifica 3.13.16; não foi esse o runtime executado. Não houve browser, mTLS, OCSP, CRL, ensaio de expiração ou deployment de renovação. O curso de TLS e Certificados contém um laboratório offline complementar. Conclui esta aula com três hipóteses separadas para uma falha: confiança, nome e seleção HTTP, indicando que observação distinguiria cada uma.
"""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']}))
Num teste anterior à migração DNS, o URL com IP falha mesmo com Host correto; o URL com nome e --resolve passa com confiança explícita.
Armadilhas comuns
Pedir renovação só pela saída 60, usar Host como identidade TLS ou considerar que --resolve demonstrou o comportamento do DNS público.
Tópicos relacionados: O pedido e a representação pretendida · HTTPS, identidade e saltos de proxy · Diagnóstico e orçamento de tempo
Um diagnóstico útil identifica a condição que falhou e o cliente que a verificou, preservando o nome e a confiança pretendidos para o serviço.
Referência: curl TLS certificate verification · DR HTTP/HTTPS 2026-09; HTTP RFCs 9110–9114; selected TLS 1.3 and NGINX/curl guidance