概要
2024年7月改訂
この記事では TLS 対応の HTTP/1/2 サーバーをつくるために調べたことの記録です。2024年の時点では WSGI (Web Server Gateway Interface) や ASGI (Asynchronous Server Gateway Interface) に対応した HTTP サーバーがたくさんあるので、実際の使用よりも学習を目的としています。h2 モジュールは HTTP/2 対応のためにさまざまなプロジェクトで使われています。
Python の開発環境
最新の Python を導入するため、homebrew などの開発環境構築ツールがおすすめです。2024年7月の時点で最新バージョンは Python 3.12 です。2024年になってから私は mise という開発環境構築ツールを使うようになりました。homebrew との違いは依存関係のライブラリを自分で導入する必要があることです。
自己署名証明書と秘密鍵の生成
TLS 対応の HTTP サーバーを動かすためには証明書と秘密鍵が必要です。mkcert (Go で開発) を導入すれば毎回 openssl
のコマンドをコピペしなくてすみます。mkcert の作者の FiloSottile 氏は Go の標準ライブラリである crypto の TLS 1.3 実装を担当したそうです。
homebrew
や apt
などさまざまなパッケージ管理ツールで導入できます。localhost に対して証明書と秘密鍵を生成するコマンドは次のようになります。
mkcert localhost
生成された localhost.pem
は証明書、localhost-key.pem
は秘密鍵です。
ECDSA(Elliptic Curve Digital Signature Algorithm) を使いたい場合、-ecdsa
を指定します。
mkcert -ecdsa localhost
openssl
コマンドを使う場合、次のワンライナーで自己署名証明書および秘密鍵を生成できます。
# https://stackoverflow.com/a/41366949/531320
openssl req -x509 -newkey rsa:4096 -sha256 \
-nodes -keyout server.key -out server.crt \
-subj "/CN=localhost" -days 3650
RSA 証明書の場合、鍵の長さは2048ビット以上が推奨とされています。こちらのブログ記事によると米国連邦政府の暗号のガイドラインである NIST は2048ビットの使用期限は2030年12月31日までとしているそうです。
2024年の HTTP サーバーの選択肢
ASGI (Asynchronous Server Gateway Interface) や HTTP/2/3 をサポートする HTTP サーバーが望ましいと思われます。
- Hypercorn - HTTP/2 (h2 モジュール)、HTTP/3 (aioquic モジュール) に対応
- Granian - HTTP/2 に対応。Rust で開発
Hypercorn
次のコマンドで Hypercorn をインストールします
pip install hypercorn
起動方法は次のとおりです。
hypercorn --certfile localhost.pem --keyfile localhost-key.pem hello:app
async def app(scope, receive, send):
if scope["type"] != "http":
raise Exception("Only the HTTP protocol is supported")
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
(b'content-type', b'text/plain'),
(b'content-length', b'5'),
],
})
await send({
'type': 'http.response.body',
'body': b'hello',
})
Granian
pip
でインストールできます
pip install granian
サーバーの起動方法は次のとおりです。
granian --interface asgi --port 8000 \
--ssl-certificate localhost.pem --ssl-keyfile localhost-key.pem \
hello:app
hello.py
のコードは上述の Hypercorn と同じです。
HTTP/2/3 対応のベンチマークツール
2024年時点で HTTP/2 に対応しているのは h2load
です。homebrew なら nghttp2、Debian なら nghttp2-client から導入できます。h2load を自分でビルドすれば HTTP/3 対応にできます。広く使われている Apache Bench (ab) や wrk/wrk2 は HTTP/1 止まりなので、HTTP/2/3 対応のサーバーの性能を十分に評価できない可能性があります。
curl の HTTP/2/3 対応
最新の curl では HTTP/2 はデフォルト対応になっています
curl -v https://localhost:8000
サーバーが HTTP/2 に対応していることがわかっているのであれば --http2-prior-knowledge
を指定します
curl --http2-prior-knowledge -v https://localhost:8000
2024年7月時点で HTTP/3 対応バージョンを利用するには自分でビルドするか Docker を利用する必要があります。HTTP/3 対応がわかっているサーバーであれば --http3-only
を指定します
curl --http3-only https://localhost:8000/
2025年リリースの Debian 13 trixie では GnuTLS で HTTP/3 に対応する予定です。
SSLContext
サーバーをつくる作業に入る前に SSLContext の生成について学びます。
生成
一般的な使い方では ssl.create_default_context
が推奨されています。
>>> import ssl
>>> ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
>>> ctx.options
<Options.OP_NO_COMPRESSION|
OP_ENABLE_MIDDLEBOX_COMPAT|
OP_CIPHER_SERVER_PREFERENCE|
OP_NO_SSLv3|
OP_ALL: 2186412112>
指定できる定数は Purpose.SERVER_AUTH
(クライアントサイドのソケットを作るのに使う)と Purpose.CLIENT_AUTH
(サーバサイドのソケットを作るのに使う) です。options
の値は Python 3.13.0b3 で確認したものです。
オプションの項目の意味は次のとおりです。
- OP_NO_COMPRESSION - SSL チャネルでの圧縮を無効
- OP_ENABLE_MIDDLEBOX_COMPAT - TLS 1.3 のハンドシェイクのときに TLS 1.2 のように見せかけるためにダミーの Change Cipher Spec (CCS) を送る
- OP_CIPHER_SERVER_PREFERENCE - クライアントよりもサーバーの暗号を優先する
- OP_NO_SSLv3 - SSLv3 接続を禁止する
- OP_ALL - 通信相手の SSL 実装のバグを避ける次善策の機能を有効にする
SSLContext
のコンストラクターから直接生成することもできます。
>>> ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
>>> ctx.options
<Options.OP_NO_COMPRESSION|
OP_ENABLE_MIDDLEBOX_COMPAT|
OP_CIPHER_SERVER_PREFERENCE|
OP_NO_SSLv3|
OP_ALL: 2186412112>
Python 3.10 からコンストラクターの引数に PROTOCOL_TLS_CLIENT
もしくは PROTOCOL_TLS_SERVER
のどちらを指定することが必須となりました。
TLS のバージョンの上限と下限
Python 3.7 から TLS のバージョンの上限と下限を設定できるようになりました。REPL でデフォルトのバージョンを確認してみます。
>>> ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
>>> ctx.maximum_version
<TLSVersion.MAXIMUM_SUPPORTED: -1>
>>> ctx.minimum_version
<TLSVersion.TLSv1_2: 771>
練習として上限のバージョンを TLS 1.3 に設定してみます。
>>> ctx.maximum_version=ssl.TLSVersion.TLSv1_3
>>> ctx.maximum_version
<TLSVersion.TLSv1_3: 772>
ALPN で提示するプロトコルの優先順位
TLS 拡張である ALPN (Application-Layer Protocol Negotiation) においてプロトコルの優先順位を指定することができます。次のコードは HTTP/2 を HTTP/1.1 よりも優先することを意味します。
>>> ctx.set_alpn_protocols(["h2", "http/1.1"])
暗号スイート
暗号スイート (Ciphersuites) は get_ciphers
で確認できます。暗号スイートを変更するには set_ciphers
を使います。
>>> import ssl
>>> ctx = ssl.create_default_context().get_ciphers()
>>> ctx.set_ciphers('ECDHE+AESGCM:!ECDSA')
>>> ctx.get_ciphers()
Mozilla のサイトでおすすめの暗号スイートが公開されています。TLS 1.3 に関して Cloudflare が nginx の設定ファイルを公開しています。OpenSSL 1.1.0 が TLS 1.3 の一部に対応しており、OpenSSL 1.1.1 で完全に対応する予定です。
http.server
http.server
を使って TLS 対応の HTTP/1 サーバーをつくってみます。
-m
オプションとコードの確認
次のコマンドを実行すると HTML ファイルを表示するサーバーが起動します。
python -m http.server 8000
-m
オプションはモジュールの実行を意味します。http.server
のソースコードを見ると if __name__ == '__main__':
よりも下の行で test
関数が実行されます。
test(
HandlerClass=handler_class,
ServerClass=DualStackServer,
port=args.port,
bind=args.bind,
protocol=args.protocol,
)
if __name__ == '__main__':
の上の行で定義されている test
関数を見ると httpd.serve_forever()
が実行されます。
with ServerClass(addr, HandlerClass) as httpd:
host, port = httpd.socket.getsockname()[:2]
url_host = f'[{host}]' if ':' in host else host
print(
f"Serving HTTP on {host} port {port} "
f"(http://{url_host}:{port}/) ..."
)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nKeyboard interrupt received, exiting.")
sys.exit(0)
HTTPServer の利用
http.server
モジュールに加えて、ssl
モジュールを読み込む必要があります。
SSLContext
オブジェクトを生成し、いろいろな設定を指定した上で、SSLContext.wrap_socket
を使って SSLSocket
オブジェクトを生成します。
from http.server import HTTPServer, SimpleHTTPRequestHandler
import ssl
def run(host, port, ctx, handler):
server = HTTPServer((host, port), handler)
server.socket = ctx.wrap_socket(server.socket)
print('Server Starts - %s:%s' % (host, port))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
print('Server Stops - %s:%s' % (host, port))
if __name__ == '__main__':
host = 'localhost'
port = 8000
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain('localhost.pem', keyfile='localhost-key.pem')
ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
handler = SimpleHTTPRequestHandler
run(host, port, ctx, handler)
ssl.OP_NO_TLSv1
と ssl.OP_NO_TLSv1_1
はそれぞれ TLS 1.0、TLS 1.1 を無効にすることを意味します。
WSGI への対応
標準の wsgiref
モジュールを使えば WSGI に対応したサーバーを立てることができます。
from wsgiref.simple_server import make_server
from pathlib import Path
import ssl
def simple_app(env, start_response):
info = env['PATH_INFO'][1:]
if info == '':
info = 'index.html'
root = Path.cwd()
path = root.joinpath(info).resolve()
if root in path.parents and path.is_file():
status = '200 OK'
body = path.read_bytes()
suffix = path.suffix
content_type = {
'.html': 'text/html',
'.txt': 'text/plain',
'.json': 'application/json'
}.get(suffix, 'text/plain')
else :
body = b'404 Not Found'
status = '404 Not Found'
content_type = 'text/plain'
headers = [
('Content-Type', content_type),
('Content-Length', str(len(body)))
]
start_response(status, headers)
return [body]
def run(host, port, ctx, app):
server = make_server(host, port, app)
server.socket = ctx.wrap_socket(server.socket)
print('Server Starts - %s:%s' % (host, port))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
print('Server Stops - %s:%s' % (host, port))
if __name__ == '__main__':
host = 'localhost'
port = 8000
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain('localhost.pem', keyfile='localhost-key.pem')
ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
run(host, port, ctx, simple_app)
定義されているすべての環境変数 (env
) が知りたいのであれば、PEP-333 をご参照ください。wsgiref.simple_server.demo_app
を実行アプリにしたときに表示されるページから確認することもできます。
asyncio
asyncio を使った HTTP/1 サーバーをつくることに取り組みます。公式マニュアルを調べるといろいろなコードサンプルが記載されています。
async で宣言した関数
async で宣言した関数を使ってサーバーをつくってみます。
import asyncio
import ssl
# https://docs.python.org/ja/3/library/asyncio-stream.html#tcp-echo-server-using-streams
# https://stackoverflow.com/a/76933377/531320
async def handle_echo(reader, writer):
data = (await reader.read(1024)).decode()
addr = writer.get_extra_info('peername')
print(f"Received {data!r} from {addr!r}")
msg =(
'HTTP/1.1 200 OK\r\n' \
'Content-Type: text/html; charset=UTF-8\r\n' \
'Content-Length: 13\r\n' \
'Connection: Close\r\n' \
'\r\n' \
'Hello World\r\n' \
).encode()
writer.write(msg)
await writer.drain()
writer.close()
await writer.wait_closed()
async def main():
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain('localhost.pem', 'localhost-key.pem')
ctx.set_alpn_protocols(['http/1.1'])
server = await asyncio.start_server(
handle_echo, '127.0.0.1', 8000, ssl=ctx
)
addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
print(f'Serving on {addrs}')
async with server:
await server.serve_forever()
asyncio.run(main())
asyncio.Protocol を継承するクラス
今度は asyncio.Protocol
を継承するクラスを使ってみます。
import asyncio
import ssl
# https://docs.python.org/ja/3/library/asyncio-protocol.html#tcp-echo-server
# https://github.com/python/cpython/issues/109534
class EchoServerProtocol(asyncio.Protocol):
def connection_made(self, transport):
peername = transport.get_extra_info('peername')
print('Connection from {}'.format(peername))
self.transport = transport
def data_received(self, data):
msg =(
'HTTP/1.1 200 OK\r\n' \
'Content-Type: text/html; charset=UTF-8\r\n' \
'Content-Length: 13\r\n' \
'Connection: Close\r\n' \
'\r\n' \
'Hello World\r\n'
).encode()
self.transport.write(msg)
self.transport.close()
async def main():
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain('localhost.pem', 'localhost-key.pem')
ctx.set_alpn_protocols(['http/1.1'])
loop = asyncio.get_running_loop()
server = await loop.create_server(
lambda: EchoServerProtocol(),
'127.0.0.1', 8000, ssl=ctx)
async with server:
await server.serve_forever()
asyncio.run(main())
HTTP/2 に対応させる
h2 のプロジェクトに asyncio を使った HTTP/2 サーバーのサンプルコードがある (asyncio-server.py)。h2 は次のコマンドでインストールできる
pip install h2
そのままではサンプルコードは動かないのでコードを修正する。ssl.create_default_context
から下のコードを次のものに書き換えれば動く。上記の asyncio.Protocol
を継承するクラスで使ったコードの流用である
async def main():
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain('localhost.pem', 'localhost-key.pem')
ctx.set_alpn_protocols(['h2', 'http/1.1'])
loop = asyncio.get_running_loop()
server = await loop.create_server(
lambda: H2Protocol(),
'127.0.0.1', 8000, ssl=ctx)
async with server:
await server.serve_forever()
asyncio.run(main())
h2 の使い方を学ぶ上で読むべきコードは data_received
の定義である。
def data_received(self, data: bytes):
try:
events = self.conn.receive_data(data)
except ProtocolError as e:
self.transport.write(self.conn.data_to_send())
self.transport.close()
else:
self.transport.write(self.conn.data_to_send())
for event in events:
if isinstance(event, RequestReceived):
self.request_received(event.headers, event.stream_id)
elif isinstance(event, DataReceived):
self.receive_data(
event.data, event.flow_controlled_length, event.stream_id
)
elif isinstance(event, StreamEnded):
self.stream_complete(event.stream_id)
elif isinstance(event, ConnectionTerminated):
self.transport.close()
elif isinstance(event, StreamReset):
self.stream_reset(event.stream_id)
elif isinstance(event, WindowUpdated):
self.window_updated(event.stream_id, event.delta)
elif isinstance(event, RemoteSettingsChanged):
if SettingCodes.INITIAL_WINDOW_SIZE in event.changed_settings:
self.window_updated(None, 0)
self.transport.write(self.conn.data_to_send())
events = self.conn.receive_data(data)
はクライアントからのデータ (data
) が解析される(receive_data
) と複数のイベントオブジェクト (events
) が生成されることを意味する。イベントの種類は RequestReceived
や DataReceived
といったものがある。
HTTP/2 フレーム
ここで HTTP/2 のデータのやりとりの基本単位であるフレームについて説明する。
フレームは9バイトのヘッダーと任意長のペイロードで構成される。ヘッダーの内訳は次のとおり
- ペイロードのバイト数 - 3バイト
- フレームの種類 - 1バイト、
0x00
から0x09
- フラグ - 1バイト
- ストリーム id - 4バイト
h2 のイベントはフレームの種類やフラグなどに対応している。
クライアントとサーバーの HTTP/2 フレームのやりとりは次のようになる。
- クライアント・サーバーがお互いに Settings (0x04) フレームを送信し、データの上限などのルールを提示する
- 相手の Settings (0x04) フレームに了承したら ACK (0x04) を送信する
- クライアント・サーバーがそれぞれ Headings (0x01)、Data (0x00) フレームを送信する
- クライアントが GOAWAY (0x07) フレームを送信して通信を終了させる
具体的な HTTP/2 フレームがどのように解析され、イベントが生成されるのか示す。次のデータは クライアントが Settings フレームのデフォルト値を示すものである。
>>> import h2.connection
>>> import h2.events
>>> data = b'\x00\x00\x00\x04\x00\x00\x00\x00\x00'
>>> c = h2.connection.H2Connection()
>>> events = c.receive_data(data)
>>> [event for event in events]
[<RemoteSettingsChanged changed_settings:{}>]
>>> [isinstance(event, h2.events.RemoteSettingsChanged) for event in events]
[True]
ほかのフレームのデータはこちらの Gist を参照。具体的な HTTP/2 フレームのデータのやりとりに関しては2015年に書かれたこちらの記事がわかりやすい
その他
CGI サポートの打ち切り
CGI (CGIHTTPRequestHandler) は Python 3.13 (2024年10月リリース予定) から非推奨となり、Python 3.15 (2026年10月リリース予定) で削除される予定です。古いシステムのために記録として残しておきます。--cgi
オプションを指定して起動できます。
python3 -m http.server --cgi 8000
実行したいスクリプトは /cgi-bin
もしくは /htbin
に設置する必要があります。
#!/usr/bin/env python3
print("Content-Type: text/plain; charset=utf-8;\r\n")
print("hello world")
CGIHTTPRequestHandler
の使い方について以前掲載していたプログラムコードは Gist に移行しました。
ssl.wrap_socket の非推奨化
Python 3.7 で ssl.wrap_socket()
を使うことは非推奨化され、Python 3.12.0 で削除されました (リリースノート)。代わりにSSLContext.wrap_socket()
を使います。
TLS の最小バージョン
クレジットカードの決済サービスに採用される PCI DSS v3.2 は2018年6月末日まで SSL および TSL 1.0 を無効にすることを求めています。PyPI に採用されている CDN サービスの fastly は TLS 1.1 も無効にします。
システムに導入されている Python 3 がサポートする TLS のバージョンは次のワンライナーで調べることができます。
# https://news.ycombinator.com/item?id=13539034
python3 -c "import json, urllib.request; print(json.loads(urllib.request.urlopen('https://www.howsmyssl.com/a/check').read().decode('UTF-8'))['tls_version'])"
却下された TLS の新しい API の提案
2016年に提出された PEP 543 は TLS のための新しい API の導入を提案されたものの却下されました。開発の動機として、ssl モジュールは OpenSSL 依存しているために新しい OpenSSL を導入するには Python を再コンパイルしなければならないことや OpenSSL とは異なるさまざまな TLS ライブラリに切り換えることができないことが挙げられています。
主な SSL/TLS ライブラリは次のとおりです。
- Schannel SSP - Windows
- Secure Transport - macOS
- BoringSSL - Android
- LibreSSL - OpenBSD
- TLS in Linux kernel (4.1.3)
- picotls - HTTP/2 サーバーの h2o プロジェクトで開発
- s2n - Amazon