99
112

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2017-09-01

概要

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 実装を担当したそうです。

homebrewapt などさまざまなパッケージ管理ツールで導入できます。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
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 でインストールできます

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 関数が実行されます。

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

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

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 オブジェクトを生成します。

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_TLSv1ssl.OP_NO_TLSv1_1 はそれぞれ TLS 1.0、TLS 1.1 を無効にすることを意味します。

WSGI への対応

標準の wsgiref モジュールを使えば WSGI に対応したサーバーを立てることができます。

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 をご参照ください。wsgiref.simple_server.demo_app を実行アプリにしたときに表示されるページから確認することもできます。

asyncio

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

async で宣言した関数

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

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 を継承するクラスを使ってみます。

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)。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) が生成されることを意味する。イベントの種類は RequestReceivedDataReceived といったものがある。

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
99
112
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
99
112

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?