🕌

【Python】asyncio を使って TLS 対応の HTTP/1 サーバーをつくる

2024/07/04に公開

次のコマンドでサーバーを起動させて https//localhost:8000 に curl かブラウザーでアクセスする

python server.py

curl のコマンドは次のとおり

curl -v https://localhost:8000

TLS 証明書と秘密鍵は mkcert で生成した

mkcert localhost
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())

writer.start_tls を使って TLS に対応させることもできるが、Chrome でアクセスするといろいろなエラーメッセージを吐き出した。サーバーそのものは止まらず動いた。

async def handle_echo(reader, writer):
    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'])

    await writer.start_tls(ctx)

    data = (await reader.read(1024)).decode()
    addr = writer.get_extra_info('peername')

サーバーを起動させていて出力されたエラーメッセージは次のとおり

Unhandled exception in client_connected_cb
transport: <_SelectorSocketTransport closed fd=7>
Traceback (most recent call last):
  File "/home/masakielastic/asyncio-project/server.py", line 9, in handle_echo
    await writer.start_tls(ctx)
  File "/home/masakielastic/.local/share/mise/installs/python/3.13.0b3/lib/python3.13/asyncio/streams.py", line 396, in start_tls
    new_transport = await self._loop.start_tls(  # type: ignore
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
        ssl_shutdown_timeout=ssl_shutdown_timeout)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/masakielastic/.local/share/mise/installs/python/3.13.0b3/lib/python3.13/asyncio/base_events.py", line 1338, in start_tls
    await waiter
  File "/home/masakielastic/.local/share/mise/installs/python/3.13.0b3/lib/python3.13/asyncio/sslproto.py", line 578, in _on_handshake_complete
    raise handshake_exc
  File "/home/masakielastic/.local/share/mise/installs/python/3.13.0b3/lib/python3.13/asyncio/sslproto.py", line 560, in _do_handshake
    self._sslobj.do_handshake()
    ~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/home/masakielastic/.local/share/mise/installs/python/3.13.0b3/lib/python3.13/ssl.py", line 952, in do_handshake
    self._sslobj.do_handshake()
    ~~~~~~~~~~~~~~~~~~~~~~~~~^^

ほかに 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())

Discussion