Learning Platform
Глоссарий Troubleshooting
Урок 17.03 · 25 мин
Начальный
TLSPythonssl.SSLContextself-signedhttp.serveropenssl

Свой 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).


Архитектура — три файла, ничего больше

Нам нужны:

  1. shop.local.key — приватный RSA-ключ на 2048 бит.
  2. shop.local.crt — self-signed сертификат с CN=shop.local и SAN=DNS:shop.local.
  3. server.py — Python-скрипт, который собирает SSLContext и запускает HTTPServer.

Всё. Никаких зависимостей, никаких pip install, никаких docker. На любой машине, где есть Python 3.11+ и openssl, это запускается за минуту.

Что строим -- HTTPS-сервер на двух модулях stdlib

http.server + ssl, ничего больше

openssl reqУтилита из openssl. Генерирует RSA-ключ и self-signed сертификат за одну команду с -newkey, -x509, -nodes
produce
shop.local.key + shop.local.crtКлюч -- 2048-битный RSA в формате PEM. Cert -- X.509 v3 с подписью приватным ключом и SAN. PEM-формат: base64 + header BEGIN/END
ssl.SSLContextОбъект Python, инкапсулирующий настройки TLS: версии, ciphers, сертификат, ключ. Создаётся через PROTOCOL_TLS_SERVER
wraps
HTTPServer.socketctx.wrap_socket(sock, server_side=True). Превращает обычный TCP-socket в TLS-сокет. Дальше HTTPServer работает как с обычным сокетом
curl --cacertКлиент. Проверяет cert по нашему shop.local.crt как trusted CA. Если совпадает CN/SAN с URL и подпись валидна -- handshake успешен
connects
200 OK + bodyПосле handshake -- обычный HTTP-обмен внутри TLS-туннеля. Скрипт отдаёт {'hello': 'tls'} JSON

Шаг 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 смотрят сюда.
WARNING

Если вы укажете только 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/hosts127.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, без причины.


Попробуй сам

  1. Создай 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"
  1. Запусти server.py с этими файлами на порту 9443.

  2. Сделай два запроса:

curl --cacert mysvc.crt https://mysvc.local:9443/   # должно работать
curl https://mysvc.local:9443/                       # должно упасть с self-signed
  1. Запусти захват трафика и сравни TLS handshake (tshark -r trace.pcap -Y 'tls.handshake').

  2. Бонус: попробуй сделать cert на 0 дней валидности (-days 0) — посмотри, как curl сообщит об истечении.


Что делать в production

В production self-signed cert не используется. Стандартные опции:

Откуда брать cert в production

Free / commercial / internal -- три источника

Let's EncryptFree, automated, 90 дней валидности. Через certbot или ACME-клиент. Подходит для public-сайтов с реальными доменами
DigiCert / SectigoПлатные, валидация компании (EV/OV), 1-2 года. Для крупного бизнеса где доверие важнее цены
Internal CAСвоя CA (HashiCorp Vault, AWS PCA, Cloudflare Origin). Для internal services внутри сети. Сертификаты подписываются вашей CA, клиенты доверяют этой CA

Self-signed остаётся для dev и для тестов. Как только вы научились его делать руками — остальные сценарии становятся понятны: Let’s Encrypt — это та же команда, но с подписью от их CA вместо самоподписи.


HTTPS в Docker: self-signed cert в dev-контейнере и Let's Encrypt в prod
Проверка знанийKnowledge check
Вы подняли HTTPS-сервер на Python с self-signed cert, в SAN указали 'DNS:shop.local'. curl --cacert shop.local.crt https://shop.local:8443/ работает. Но коллега жалуется: его Python-клиент urllib3 кидает 'CERTIFICATE_VERIFY_FAILED: self signed certificate'. Он передал тот же cert в verify=shop.local.crt параметр. Что могло пойти не так?
ОтветAnswer

Итог

В этом уроке мы построили HTTPS-сервер на чистом Python:

  • Self-signed certopenssl req -x509 -newkey rsa:2048 -addext subjectAltName=...
  • SSLContextssl.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), которые вы будете использовать всю карьеру.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Вы создали self-signed сертификат командой 'openssl req -x509 -newkey rsa:2048 -keyout key -out crt -subj "/CN=mysvc.local"'. БЕЗ флага -addext с subjectAltName. Запускаете HTTPS-сервер с этим cert. Какой результат при curl --cacert crt https://mysvc.local:443/?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4