Bản ghi DNS và định tuyến HTTP trả lời những câu hỏi khác nhau
Theo dõi bí danh đến máy chủ HTTPS cục bộ, quan sát riêng TLS SNI và HTTP Host, rồi phân biệt lỗi chứng chỉ với lỗi chọn tuyến ứng dụng.

Tên miền phân giải ra đúng địa chỉ IP chưa chứng minh yêu cầu HTTPS sẽ đến đúng website. Máy khách còn phải thiết lập kết nối, chấp nhận chứng chỉ của máy chủ cho tên được yêu cầu và gửi yêu cầu HTTP mà máy chủ định tuyến đúng. Nhiều website có thể dùng chung một địa chỉ nhưng có tên và hành vi ứng dụng khác nhau.
Bài này dành cho lập trình viên mới bắt đầu tìm lỗi hạ tầng web. Bạn cần kiến thức cơ bản về dòng lệnh và Python. Ta dùng thư viện chuẩn Python, curl và OpenSSL để khởi chạy máy chủ HTTPS trên giao diện loopback. Thí nghiệm đã được kiểm tra với Python 3.12, curl 8.5.0 và OpenSSL 3.0.13. Nó không sửa tệp phân giải tên của máy, bản ghi DNS công khai hay dịch vụ có sẵn.
Theo dõi tên ban đầu qua các bước của yêu cầu
Xét hai bản ghi được khai báo: shop.lab.test CNAME origin.lab.test và origin.lab.test A 127.0.0.1. Tài liệu DNS của Cloudflare mô tả A và AAAA là ánh xạ đến địa chỉ IPv4 và IPv6, còn CNAME ánh xạ đến một tên chuẩn khác. Đi theo bí danh này có thể tìm được địa chỉ cho tên mà máy khách hỏi. Nó không yêu cầu máy khách HTTP thay URL bằng tên chuẩn.
Từ điển bản ghi nhỏ trong ví dụ mô hình hóa chuỗi đó. Đây không phải máy chủ DNS, bản bắt gói tin hay phép thử bộ phân giải đệ quy. Sau đó, tùy chọn --resolve của curl cung cấp địa chỉ đã chọn cho đúng cặp tên và cổng. Nhờ vậy, ví dụ chạy hoàn toàn cục bộ mà vẫn giữ tên trong URL. DNS thực tế có thêm cache, nhiều câu trả lời và các kiểu lỗi khác; từ điển không triển khai những hành vi ấy.
Địa chỉ trả lời câu hỏi sẽ thử kết nối đến đâu. Trong thí nghiệm này, URL HTTPS còn chỉ rõ cổng. Khi thiết lập TLS, curl gửi tên trong URL qua Server Name Indication, viết tắt là SNI, rồi kiểm tra chứng chỉ theo tên được yêu cầu. SNI giúp máy chủ phân biệt các tên dùng chung địa chỉ; nó không phải thông tin chứng minh quyền truy cập ứng dụng. RFC 6066 định nghĩa phần mở rộng tên máy chủ và mô tả khả năng sử dụng nó để chọn chứng chỉ.
Sau khi TLS thành công, yêu cầu HTTP/1.1 mang trường Host chứa tên đích và, trong ví dụ này, cổng được chỉ rõ. Máy chủ có thể dùng thông tin đó để chọn website. RFC 9110 mô tả vai trò định tuyến ứng dụng này và nhắc đến trường :authority tương ứng trong HTTP/2 và HTTP/3. Ví dụ buộc dùng HTTP/1.1 để trường được quan sát không gây nhập nhằng.
Quan sát các tên bằng máy chủ HTTPS cục bộ
Lưu chương trình dưới đây thành routing_lab.py, rồi chạy python3 -I routing_lab.py. Nó tạo chứng chỉ tự ký có thời hạn một ngày, chứa cả hai tên thử nghiệm, và chỉ tin chứng chỉ đó trong từng lệnh curl. Khóa cùng chứng chỉ nằm trong thư mục tạm. Chúng không được cài vào kho tin cậy của hệ thống.
Máy chủ chỉ lắng nghe trên 127.0.0.1 và nhờ hệ điều hành chọn một cổng còn trống. Hàm callback của TLS ghi lại SNI được gửi đến, còn bộ xử lý HTTP ghi Host và chọn một trong hai tuyến khai báo trực tiếp. Bộ xử lý được cố ý giữ nhỏ: bước tra tuyến chỉ khớp chính xác các tên ASCII được nêu. Nó không phải bộ phân tích tên máy chủ hoặc lớp phân quyền dùng trong vận hành.
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()
Cổng được thay bằng PORT trong kết quả in ra để dễ so sánh giữa các lần chạy:
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
Hai yêu cầu đầu đến cùng một socket đang lắng nghe nhưng giữ các tên khác nhau. URL dùng bí danh nhận tuyến storefront; URL dùng tên origin nhận tuyến origin. Chứng chỉ tạm bao phủ cả hai tên, nên cả hai có thể vượt qua bước kiểm tra danh tính của ví dụ. Dùng chung địa chỉ IP không khiến thông tin đích ở lớp HTTP trở nên thay thế cho nhau được.
Chương trình vô hiệu hóa tệp cấu hình mặc định của curl bằng tùy chọn -q đứng đầu, bỏ qua proxy và dùng môi trường tiến trình con giới hạn. Timeout kết nối và timeout toàn bộ giới hạn thời gian gọi máy khách. Khối finally dừng máy chủ do chương trình tạo và đóng socket. Các lựa chọn này giúp giải thích lưu lượng quan sát được cùng việc dọn dẹp mà không phụ thuộc cấu hình curl cá nhân của người chạy.
Đổi Host không đổi tên TLS mà curl gửi
Yêu cầu thứ ba vẫn dùng URL https://shop.lab.test:PORT/ nhưng cung cấp Host: origin.lab.test. SNI quan sát được vẫn là shop, còn bộ xử lý tùy biến chọn tuyến origin. Đây là sự không khớp có chủ đích nhằm xem riêng hai đầu vào. Reverse proxy thực có thể từ chối sự không khớp ấy hoặc dùng quy tắc định tuyến khác; việc một máy chủ này chấp nhận không phải bảo đảm tương thích chung.
Chương về phân giải tên trong everything curl giải thích vì sao chỉ sửa trường Host chưa đủ để kiểm tra HTTPS theo tên: curl lấy SNI từ URL. Chương cũng giải thích rằng --resolve thay cách chọn địa chỉ trong curl nhưng giữ tên URL cho SNI và kiểm tra chứng chỉ. Đó là thao tác hữu ích khi muốn kiểm tra một website có tên cụ thể tại một địa chỉ cụ thể.
Vì vậy, thay tên trong URL bằng địa chỉ IP rồi thêm trường Host là một thí nghiệm khác. Nó đổi danh tính mà curl được yêu cầu kiểm tra ở bước TLS. Tắt kiểm tra chứng chỉ có thể cho phép yêu cầu đó đi tiếp, nhưng đồng thời loại bỏ đúng phép kiểm tra tên mà ta cần giữ. Nên giữ tên mong muốn trong URL và cung cấp địa chỉ một cách tường minh.
Yêu cầu thứ tư cho thấy một khác biệt nữa: TLS thành công với tên shop, nhưng Host chỉ đến tuyến không có trong bảng của bộ xử lý. Phản hồi là HTTP 404, kèm các tên đã ghi nhận và no-route. curl thoát thành công vì lệnh này không yêu cầu coi trạng thái lỗi HTTP là lỗi tiến trình. Mã kiểm tra cả kết quả tiến trình lẫn trạng thái HTTP, thay vì đồng nhất mã thoát 0 với việc đã chọn được tuyến ứng dụng thành công.
Xác định lớp gặp lỗi trước khi sửa cấu hình của lớp đó
Yêu cầu cuối dùng tên wrong.lab.test nhưng cung cấp trường Host của shop. Chứng chỉ không bao phủ tên trong URL, nên curl thoát với mã 60 trước khi gửi yêu cầu HTTP. Bộ đếm yêu cầu HTTP của máy chủ không đổi. Giá trị Host đúng không sửa được lỗi chứng chỉ khiến yêu cầu chưa đến lớp HTTP.
Chỉ mã thoát chưa đủ để chẩn đoán đầy đủ: curl cũng có thể dùng mã 60 cho những lỗi kiểm tra chứng chỉ khác. Hãy giữ thông báo lỗi và xem điều kiện về độ tin cậy hay tên nào đã thất bại. Trong tình huống có kiểm soát này, cùng chứng chỉ được tin cậy đã thành công với shop và origin; chỉ tên trong URL được đổi thành tên không có trong danh sách subject alternative names của chứng chỉ.
Khi điều tra thực tế, hãy ghi URL, địa chỉ và cổng được chọn, kết quả kiểm tra chứng chỉ, thông tin đích của HTTP và phản hồi. Nếu chưa có địa chỉ, kiểm tra phân giải tên. Nếu kết nối bị từ chối, kiểm tra tiến trình lắng nghe và đường kết nối đến nó. Nếu kiểm tra chứng chỉ thất bại, xem độ tin cậy và tên được bao phủ trước khi thử tuyến ứng dụng. Nếu HTTP đến nhầm website, xem cấu hình ánh xạ tên đích sang tuyến. Những quan sát này chỉ ra bước kiểm tra hữu ích tiếp theo; riêng chúng chưa xác định được ai hoặc lần triển khai nào gây ra vấn đề.
Sửa CNAME không thể thêm tên còn thiếu vào chứng chỉ hay tạo tuyến ứng dụng. Chuyển hướng HTTP sang URL khác cũng khác bí danh DNS: đó là phản hồi HTTP yêu cầu máy khách tạo một yêu cầu khác đến vị trí mới. Yêu cầu ban đầu phải đến được máy chủ HTTP thì mới nhận được phản hồi ấy. Riêng CNAME không cung cấp mã chuyển hướng hay đường dẫn URL.
Để luyện tập, bỏ origin.lab.test khỏi danh sách tên của chứng chỉ được sinh. URL dùng trực tiếp origin lúc này sẽ thất bại khi kiểm tra danh tính, trong khi URL shop với Host bị đổi thành origin vẫn đến bộ xử lý cho phép sự không khớp này. Sau đó khôi phục chứng chỉ và xóa mục origin khỏi bảng tuyến: TLS sẽ thành công nhưng tuyến trả về 404. Thay đổi từng phần riêng để bằng chứng chỉ rõ quy tắc nào đã bị đổi.
Cuối cùng, đổi đích của bí danh sang khóa không tồn tại hoặc tạo chu trình. Bộ phân giải từ điển nhỏ sẽ thất bại trước assertion kiểm tra địa chỉ. Thao tác này kiểm tra ranh giới của mô hình thử, không phải mã lỗi cụ thể của một bộ phân giải DNS. Muốn biết hành vi DNS thật cần một chuỗi truy vấn riêng. Thói quen hữu ích là giữ danh tính được yêu cầu trong lúc kiểm tra độc lập việc chọn địa chỉ, xác minh TLS và định tuyến HTTP.