Python 3 で簡易の HTTPS サーバーを立てる


## 概要

**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 に対して証明書と秘密鍵を生成するコマンドは次のようになります。

```sh
mkcert localhost
```

生成された `localhost.pem` は証明書、`localhost-key.pem` は秘密鍵です。

ECDSA（Elliptic Curve Digital Signature Algorithm) を使いたい場合、`-ecdsa` を指定します。

```sh
mkcert -ecdsa localhost
```

`openssl` コマンドを使う場合、次のワンライナーで自己署名証明書および秘密鍵を生成できます。

```bash
# 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ビット以上が推奨とされています。[こちら](https://pki.world-tls.com/rsa2048bit/)のブログ記事によると米国連邦政府の暗号のガイドラインである 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 をインストールします

```sh
pip install hypercorn
```

起動方法は次のとおりです。

```sh
hypercorn --certfile localhost.pem --keyfile localhost-key.pem hello:app
```

```python:hello.py
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` でインストールできます

```sh
pip install granian
```

サーバーの起動方法は次のとおりです。
```sh
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 はデフォルト対応になっています

```sh
curl -v https://localhost:8000
```

サーバーが HTTP/2 に対応していることがわかっているのであれば `--http2-prior-knowledge` を指定します

```sh
curl --http2-prior-knowledge -v https://localhost:8000
```


2024年7月時点で HTTP/3 対応バージョンを利用するには自分でビルドするか Docker を利用する必要があります。HTTP/3 対応がわかっているサーバーであれば `--http3-only` を指定します

```sh
curl --http3-only https://localhost:8000/
```

2025年リリースの Debian 13 trixie では GnuTLS で HTTP/3 に対応する予定です。

## SSLContext
サーバーをつくる作業に入る前に SSLContext の生成について学びます。

### 生成

一般的な使い方では `ssl.create_default_context` が推奨されています。

```python
>>> 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` のコンストラクターから直接生成することもできます。

```python
>>> 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 でデフォルトのバージョンを確認してみます。

```python
>>> 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 に設定してみます。

```python
>>> 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 よりも優先することを意味します。

```python
>>> ctx.set_alpn_protocols(["h2", "http/1.1"])
```


### 暗号スイート

暗号スイート (Ciphersuites) は `get_ciphers` で確認できます。暗号スイートを変更するには `set_ciphers` を使います。

```python
>>> import ssl
>>> ctx = ssl.create_default_context().get_ciphers()
>>> ctx.set_ciphers('ECDHE+AESGCM:!ECDSA')
>>> ctx.get_ciphers()
```

Mozilla の[サイト](https://wiki.mozilla.org/Security/Server_Side_TLS)でおすすめの暗号スイートが公開されています。TLS 1.3 に関して Cloudflare が nginx の[設定ファイル](https://github.com/cloudflare/sslconfig)を公開しています。OpenSSL 1.1.0 が TLS 1.3 の一部に対応しており、OpenSSL 1.1.1 で完全に対応する予定です。


## http.server
`http.server` を使って TLS 対応の HTTP/1 サーバーをつくってみます。

### `-m` オプションとコードの確認

次のコマンドを実行すると HTML ファイルを表示するサーバーが起動します。

```bash
python -m http.server 8000
```

`-m` オプションはモジュールの実行を意味します。`http.server` のソースコードを見ると `if __name__ == '__main__':` よりも下の行で `test` 関数が実行されます。


```python:server.py
test(
        HandlerClass=handler_class,
        ServerClass=DualStackServer,
        port=args.port,
        bind=args.bind,
        protocol=args.protocol,
    )
```

`if __name__ == '__main__':` の上の行で定義されている `test` 関数を見ると `httpd.serve_forever()` が実行されます。

```python:server.py
    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` オブジェクトを生成します。

```python:server.py
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 に対応したサーバーを立てることができます。

```python:server.py
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](https://www.python.org/dev/peps/pep-0333/#environ-variables) をご参照ください。`wsgiref.simple_server.demo_app` を実行アプリにしたときに表示されるページから確認することもできます。

## asyncio

asyncio を使った HTTP/1 サーバーをつくることに取り組みます。公式マニュアルを調べるといろいろなコードサンプルが記載されています。

### async で宣言した関数

async で宣言した関数を使ってサーバーをつくってみます。

```python:server.py
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` を継承するクラスを使ってみます。

```python:server.py
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](https://github.com/python-hyper/h2/blob/master/examples/asyncio/asyncio-server.py))。h2 は次のコマンドでインストールできる

```sh
pip install h2
```


そのままではサンプルコードは動かないのでコードを修正する。`ssl.create_default_context` から下のコードを次のものに書き換えれば動く。上記の `asyncio.Protocol` を継承するクラスで使ったコードの流用である

```python
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` の定義である。

```python
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 フレームのデフォルト値を示すものである。

```python
>>> 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]
```

ほかのフレームのデータは[こちら](https://gist.github.com/masakielastic/c955588fd4f94d7947dc848994694f14)の Gist を参照。具体的な HTTP/2 フレームのデータのやりとりに関しては2015年に書かれた[こちら](https://qiita.com/0xfffffff7/items/0960527dca6de364daeb)の記事がわかりやすい

## その他
### CGI サポートの打ち切り

CGI (CGIHTTPRequestHandler) は Python 3.13 (2024年10月リリース予定) から非推奨となり、Python 3.15 (2026年10月リリース予定) で削除される予定です。古いシステムのために記録として残しておきます。`--cgi` オプションを指定して起動できます。


```bash
python3 -m http.server --cgi 8000
```

実行したいスクリプトは `/cgi-bin` もしくは `/htbin` に設置する必要があります。

```bash
#!/usr/bin/env python3

print("Content-Type: text/plain; charset=utf-8;\r\n")
print("hello world")
```

`CGIHTTPRequestHandler` の使い方について以前掲載していたプログラムコードは [Gist](https://gist.github.com/masakielastic/134a0a4578e97cbdc258c2b5c532935f) に移行しました。

### ssl.wrap_socket の非推奨化

Python 3.7 で `ssl.wrap_socket()` を使うことは非推奨化され、Python 3.12.0 で削除されました ([リリースノート](https://docs.python.org/ja/3.12/whatsnew/3.12.html#ssl))。代わりに`SSLContext.wrap_socket()` を使います。

### TLS の最小バージョン

クレジットカードの決済サービスに採用される PCI DSS v3.2 は2018年6月末日まで SSL  および TSL 1.0 を無効にすることを求めています。PyPI に採用されている CDN サービスの fastly は TLS 1.1 も無効にします。

システムに導入されている Python 3 がサポートする TLS のバージョンは次のワンライナーで調べることができます。

```bash
# 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](https://www.python.org/dev/peps/pep-0543/) は 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