Свой HTTPS-сервер на Python — ssl.SSLContext руками
В каждом большом web-фреймворке (Flask, FastAPI, Django) есть строчка app.run(ssl_context=...) или аналог, который делает HTTPS. За этой строчкой стоит модуль ssl из stdlib Python и десятки настроек: какие версии TLS принимаем, какие cipher suites, как проверяем клиентский сертификат, что делать с SNI. В большинстве проектов вы пишете эту строчку один раз и забываете. Но если в продакшене всплывёт «у нас сертификат не валидируется в Java-клиенте» — вы хотите понимать, что именно happens.
В этом уроке мы строим минимальный HTTPS-сервер на чистом Python — без Flask, без FastAPI. Только http.server + ssl.SSLContext. На стороне сертификата — то, что сделали в первом уроке: self-signed cert через openssl. Цель — разобрать каждую строчку и понять, что она делает на уровне TLS.
К концу урока вы будете уметь: создать self-signed cert для любого домена, поднять HTTPS на любом порту, проверить handshake через openssl s_client, диагностировать типичные ошибки (bad certificate, wrong host, protocol version mismatch).
Архитектура — три файла, ничего больше
Нам нужны:
- shop.local.key — приватный RSA-ключ на 2048 бит.
- shop.local.crt — self-signed сертификат с CN=shop.local и SAN=DNS:shop.local.
- server.py — Python-скрипт, который собирает SSLContext и запускает HTTPServer.
Всё. Никаких зависимостей, никаких pip install, никаких docker. На любой машине, где есть Python 3.11+ и openssl, это запускается за минуту.
http.server + ssl, ничего больше
Шаг 1 — генерируем self-signed cert
Команда openssl req умеет делать сразу три вещи: генерировать ключ, формировать CSR (certificate signing request) и подписывать. С флагом -x509 мы пропускаем CSR-шаг и сразу делаем self-signed cert.
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout shop.local.key \
-out shop.local.crt \
-subj "/CN=shop.local" \
-addext "subjectAltName=DNS:shop.local,DNS:localhost,IP:127.0.0.1"
Разбор каждого флага:
-x509— сразу сделать сертификат, не CSR.-nodes— «no DES», не шифровать приватный ключ паролем. Удобно для dev; на проде ключ обычно либо в HSM, либо зашифрован, либо в Vault.-days 365— срок валидности. Для self-signed обычно ставят год. Production-серты от Let’s Encrypt живут 90 дней.-newkey rsa:2048— генерируем новый RSA-ключ на 2048 бит. Можноrsa:4096(медленнее, но надёжнее) илиec:secp256r1(быстрее, современнее).-keyout shop.local.key— куда записать ключ.-out shop.local.crt— куда записать сертификат.-subj "/CN=shop.local"— subject (CN = Common Name). Исторически тут указывали домен; в современных проверках TLS клиенты смотрят SAN, не CN. CN остаётся для совместимости.-addext "subjectAltName=DNS:..."— SAN-extension. Список доменов и IP, для которых валиден сертификат. Здесь сейчас вся валидация имени — браузеры и curl смотрят сюда.
Если вы укажете только CN без SAN, многие современные клиенты выдадут ошибку «certificate is not valid for hostname». Это RFC 6125 + браузерная политика: если в cert есть SAN, CN игнорируется. Если SAN нет — разрешено fallback на CN, но не все клиенты это умеют. Всегда добавляйте SAN.
Проверим, что получилось:
openssl x509 -in shop.local.crt -text -noout | head -30
Что мы увидим:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
5d:8a:...
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN = shop.local
Validity
Not Before: May 18 14:30:00 2026 GMT
Not After : May 18 14:30:00 2027 GMT
Subject: CN = shop.local
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
...
X509v3 extensions:
X509v3 Subject Alternative Name:
DNS:shop.local, DNS:localhost, IP Address:127.0.0.1
Видим: Issuer = Subject (self-signed — сам себе подписант), SAN с нужными именами, валидность год вперёд.
Шаг 2 — HTTPS-сервер на Python
Минимальный скрипт:
# server.py
import json
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
class HelloHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"hello": "tls", "path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
# Чтобы не засорять stdout; в проде заменили бы на logging
return
def make_ssl_context(cert: str, key: str) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_3
ctx.load_cert_chain(certfile=cert, keyfile=key)
return ctx
def main():
httpd = HTTPServer(("127.0.0.1", 8443), HelloHandler)
ctx = make_ssl_context("shop.local.crt", "shop.local.key")
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
print("HTTPS on https://shop.local:8443/ (Ctrl+C to stop)")
httpd.serve_forever()
if __name__ == "__main__":
main()
Запускаем:
python3 server.py
# HTTPS on https://shop.local:8443/ (Ctrl+C to stop)
В другом терминале — проверка:
curl --cacert shop.local.crt https://shop.local:8443/
# {"hello": "tls", "path": "/"}
(Не забудьте про /etc/hosts — 127.0.0.1 shop.local.)
Разбор кода — что важно
SSLContext
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) создаёт контекст с дефолтами для серверной стороны. С Python 3.10 это эквивалент:
- Включены безопасные cipher suites.
- Включены TLS 1.2 и 1.3, отключены SSLv2/v3, TLS 1.0/1.1 (deprecated).
- Отключены небезопасные параметры (no_compression, no_ticket в некоторых случаях).
Мы дополнительно фиксируем minimum_version = TLSv1_2 и maximum_version = TLSv1_3 — даже если в новой версии Python дефолт изменится, наш сервер будет принимать только эти.
load_cert_chain
ctx.load_cert_chain(certfile, keyfile) загружает cert и ключ в контекст. Внутри Python вызывает OpenSSL’овые SSL_CTX_use_certificate_chain_file и SSL_CTX_use_PrivateKey_file. Если файлы битые или ключ не совпадает с сертификатом — кинется ssl.SSLError.
Если у вас не self-signed, а цепочка от CA (например Let’s Encrypt), то в certfile нужен fullchain.pem — ваш сертификат + промежуточные сертификаты CA. Без intermediates клиент не сможет валидировать цепочку.
wrap_socket
ctx.wrap_socket(sock, server_side=True) оборачивает обычный TCP-сокет в TLS-сокет. После этого read/write автоматически шифруют/расшифровывают. server_side=True критично — мы сервер, мы ждём ClientHello.
Альтернативный API: httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True, do_handshake_on_connect=False). С do_handshake_on_connect=False handshake происходит лениво, при первом read/write — это правильнее для асинхронных серверов, но для нашего синхронного http.server стандартный режим подходит.
Диагностика — openssl s_client
Лучший инструмент для проверки HTTPS-сервера со стороны клиента — openssl s_client. Он показывает каждый шаг handshake:
openssl s_client -connect 127.0.0.1:8443 -servername shop.local -CAfile shop.local.crt
Что мы увидим:
CONNECTED(00000003)
depth=0 CN = shop.local
verify return:1
---
Certificate chain
0 s:CN = shop.local
i:CN = shop.local
a:PKEY: rsaEncryption, 2048 (bit)
...
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUE6...
-----END CERTIFICATE-----
subject=CN = shop.local
issuer=CN = shop.local
---
SSL handshake has read 1234 bytes and written 567 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 2048 bit
This TLS session was created from "shop.local"
Protocol: TLSv1.3
Cipher: TLS_AES_256_GCM_SHA384
Самое важное:
Verification: OK— сертификат валиден относительно-CAfile.Protocol: TLSv1.3— успешно договорились TLS 1.3.Cipher: TLS_AES_256_GCM_SHA384— какой шифр выбрали.
Если Verification: self signed certificate — значит мы забыли -CAfile. Без него openssl считает наш cert не доверенным (что верно для системного CA bundle).
После handshake s_client остаётся открытым — можно вручную напечатать HTTP-запрос:
GET / HTTP/1.1
Host: shop.local
(пустая строка обязательна — маркер конца headers). Сервер ответит, и мы увидим HTTP/1.1 200 OK с JSON.
Типичные ошибки и как их читать
wrong host
curl: (60) SSL: certificate subject name 'shop.local' does not match target host name 'api.local'
Что произошло: вы ходите на api.local, но сертификат сделан для shop.local. Фикс: либо изменить URL, либо пересоздать cert с правильным SAN.
protocol version mismatch
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure
Что произошло: клиент и сервер не смогли договориться о версии TLS. Например, старый клиент с TLS 1.0, наш сервер требует TLS 1.2+. Фикс на стороне сервера: убрать minimum_version=TLSv1_2 (но это снижает security). Лучше обновить клиента.
certificate has expired
curl: (60) SSL certificate problem: certificate has expired
Что произошло: cert просрочен (Not After в прошлом). Фикс: пересоздать. На проде это самая частая причина инцидентов — ставьте мониторинг с alert’ом за 30 дней до истечения.
unable to get local issuer certificate
curl: (60) SSL certificate problem: unable to get local issuer certificate
Что произошло: cert подписан CA, которой нет в системном bundle. Для self-signed — ожидаемо. Фикс: --cacert <ваш cert> или добавить cert в системный trust store.
Современные настройки — что добавить для production-like
Наш сервер минимален. В реальном dev/staging есть несколько улучшений:
HTTP/2 ALPN
ALPN (Application-Layer Protocol Negotiation) — TLS-extension, в которой клиент и сервер договариваются о версии HTTP. Без ALPN всё работает по HTTP/1.1.
ctx.set_alpn_protocols(["h2", "http/1.1"])
http.server не поддерживает HTTP/2, так что это будет работать только если поверх ставить uvicorn/hypercorn. Но факт ALPN-negotiation в pcap уже будет.
Client cert (mTLS)
mTLS = mutual TLS = двусторонняя аутентификация. Сервер требует от клиента сертификат.
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(cafile="client-ca.crt")
С этим curl должен ходить с --cert client.crt --key client.key. Используется в B2B-API: партнёр имеет сертификат, выданный нашей CA.
Cipher suites
По умолчанию Python берёт безопасные ciphers, но можно ограничить:
ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20")
Это говорит: «использовать только ECDHE-обмен ключами с AES-GCM или ChaCha20». TLS 1.3 имеет фиксированный набор ciphers, который нельзя настроить таким API — только через OpenSSL ctx.set_ciphersuites().
Logging
В реальном сервере вы хотите видеть handshake-ошибки:
import logging
logging.basicConfig(level=logging.DEBUG)
# Python ssl логирует через ssl module
Без логирования если handshake падает — вы видите только TCP RST в pcap, без причины.
Попробуй сам
- Создай self-signed cert для
mysvc.local:
openssl req -x509 -nodes -days 30 \
-newkey rsa:2048 \
-keyout mysvc.key -out mysvc.crt \
-subj "/CN=mysvc.local" \
-addext "subjectAltName=DNS:mysvc.local"
-
Запусти
server.pyс этими файлами на порту 9443. -
Сделай два запроса:
curl --cacert mysvc.crt https://mysvc.local:9443/ # должно работать
curl https://mysvc.local:9443/ # должно упасть с self-signed
-
Запусти захват трафика и сравни TLS handshake (
tshark -r trace.pcap -Y 'tls.handshake'). -
Бонус: попробуй сделать cert на 0 дней валидности (
-days 0) — посмотри, как curl сообщит об истечении.
Что делать в production
В production self-signed cert не используется. Стандартные опции:
Free / commercial / internal -- три источника
Self-signed остаётся для dev и для тестов. Как только вы научились его делать руками — остальные сценарии становятся понятны: Let’s Encrypt — это та же команда, но с подписью от их CA вместо самоподписи.
HTTPS в Docker: self-signed cert в dev-контейнере и Let's Encrypt в prod
Итог
В этом уроке мы построили HTTPS-сервер на чистом Python:
- Self-signed cert —
openssl req -x509 -newkey rsa:2048 -addext subjectAltName=... - SSLContext —
ssl.PROTOCOL_TLS_SERVER+minimum_version+load_cert_chain. - wrap_socket — превращаем TCP-сокет в TLS-сокет одной строчкой.
- openssl s_client — диагностика handshake со стороны клиента.
- Типичные ошибки — wrong host, expired, version mismatch — умеем читать.
В следующем уроке — финальный аккорд: пишем свой troubleshooting runbook. Чек-листы для типичных проблем (DNS, TCP, TLS, HTTP), которые вы будете использовать всю карьеру.