websocket サーバーを OpenWrt で運用する

(👉 公式ドキュメント


Quick start

(ローカル PC でのテスト。OpenWrt とは無関係な websockets 自体の話)

ハローワールド

  • CUI ウィンドウを 2 つ開いて、server.py を実行すると、無限ループで強制終了するまで動き続ける。もう一つのウィンドウから client.py を実行すれば、サーバーから挨拶が返ってくる。client.py は何度でも実行し直すことができる。
  • server.py の websockets.serve が、(第 2、3 引数で定義される websocket の)コネクションが発生する毎に、(第 1 引数で定義される)hello コルーチンを実行する。
  • client.py の async with websockets.connect(uri) の記述によって、ブロック内のコードの実行後に、websocket 接続が自動的に閉じられるようになっている。

wss 化

  • リンクから localhost.pem をダウンロードして、サーバー&クライアント共通の暗号鍵として使う。
  • ハローワールドに対して、server.py は、websockets.serve にオプションの引数として ssl を追加しており、その値として使う ssl_context のために 3 行の ssl に関するコードが追加されている(それに伴う import も追加されている)。
  • ハローワールドに対して、client.py も、server.py と同様に、websockets.connect にオプションの引数として ssl を追加しており、その値として使う ssl_context に関しては(同じ暗号鍵を使っているわけだから)全く同じである。

次は一旦サンプルをリフィレシュして、クライアント側を Web ブラウザーの JavaScript コードに代えてアクセスする例を紹介している。

Web ブラウザーからのアクセス

  • JavaScript 側は単にサーバーから受け取ったメッセージを ul の li として追加して表示していくだけのもの。Python 側の websockets モジュールとは直接関係がなく、JavaScript の WebSocket クラスの話なので、割愛する。
  • サーバー側(show_time.py)は、sleep を使って、一定間隔で時刻をメッセージとしてプッシュするだけの無限ループ
  • 一応このサーバー&クライアントによって、複数のクライアント(Web ブラウザーのウィンドウやタブ)からサーバーにアクセスできることを確認するが、あくまでも、次の改良版のための叩き台に過ぎない。

メッセージのブロードキャスト(一斉配信)

  • websockets.broadcast を使って、保持している複数の websocket のセットに対して一斉配信している。
  • register というメソッドを定義して、websocket のセットを管理しているのがわかる

最後にサンプルを一新して、クライアントの一つから受け取った操作を反映して、結果を全クライアントに一斉配信するサンプルとなっている。

アプリの状態を管理する

  • JSON 形式のメッセージを送受信することで、状態を更新している。
  • 現在表示されているカウンター数と、参加ユーザー数との 2 種類の値がある。
  • クライアントの JavaScript 側では、+ - のいずれかを押すと、websocket.send を発動している。websocket.onmessage では、メッセージを受け取った際に、JSON をパースして、画面に表示している数字を書き換えているのがわかる。
  • サーバーの Python 側、counter で定義されたイベントループの最初に、websocket をセットに加えて(セットなので、重複するものは無視される)、そのセット数を参加ユーザー数として JSON 化して、ブロードキャスト。
  • + - のいずれかを押した JSON メッセージがクライアントから来た場合には、カウンター数を変更する。
  • websocket が(そのクライアントと)切断された場合は、セットから websocket を削除し、更新された参加ユーザー数をブロードキャスト。

以上が websockets 自体のちょっとしたジャブ的なクイックスタート・チュートリアル。以下からが、OpenWrt 上での websockets サーバー運用の本番となる。


Supervisor を使って OpenWrt 上のデーモンとしてサーバープログラムを動かす

まず、OpenWrt サーバーで、Python、pip が使える状態が整えられていることは前提として、websockets も supervisor も pip でインスールする:


pip install websockets
pip install supervisor

そして次のような Python コードを用意する(/www/ws/app.py):


#!/usr/bin/env python

import asyncio
import websockets

import os
import signal

async def echo(websocket):
    async for message in websocket:
        await websocket.send(f'echo: {message}')

async def main():
    # Set the stop condition when receiving SIGTERM.
    loop = asyncio.get_running_loop()
    stop = loop.create_future()
    loop.add_signal_handler(signal.SIGTERM, stop.set_result, None)

    async with websockets.unix_serve(echo, path = f"{os.environ['SUPERVISOR_PROCESS_NAME']}.sock"):
        await stop

if __name__ == "__main__":
    asyncio.run(main())
  • 基本的にはクイックスタートで作ったような単純なエコーサーバーだが、SIGTERM でループが終了するようになっている点と reuse_port オプションを有効にしている点が新しい。
  • SIGTERM 対応は、プロセスマネージャーである Supervisor での利用を想定しているため。

次に supervisord(Supervisor daemon?)の実行オプション用の設定ファイルを用意する(/www/ws/supervisord.conf)。ただし、websockets 公式の設定例が OpenWrt 的にはイマイチよろしくない部分があったので、OpenWrt 用に最適化した:


[supervisord]
user = root

[program:websockets]
command = python /www/ws/app.py
directory = /var/run
umask = 111
numprocs = 4
process_name = %(program_name)s_%(process_num)02d
autorestart = true

公式の例ではプロセスが通常の場所のファイルとして作成されてしまうので、フラッシュストレージの OpenWrt ルーターでは好ましくない。/var/run を使って確実に、RAM が使われるようにしておきたい。ネットで情報を探したが、誰も /var/run を使うようにしている例を示しているものが存在しなかったので、自力で見出した。上のように、directory 設定との組合せで process_name が /var/run に作成されるようにしたのがポイント。

動作テストは、PC 側の CUI ウィンドウを 2 つ開いて、それぞれ SSH で OpenWrt サーバーにログインして行うなどする。

1 つ目のウィンドウで SSH して、次のコマンドで supervisord を起動する(cd /www/ws 前提):


supervisord -c supervisord.conf -n
2023-12-04 22:13:34,534 INFO Set uid to user 0 succeeded
2023-12-04 22:13:34,671 INFO supervisord started with pid 3749
2023-12-04 22:13:35,689 INFO spawned: 'websockets_00' with pid 3750
2023-12-04 22:13:35,732 INFO spawned: 'websockets_01' with pid 3751
2023-12-04 22:13:35,797 INFO spawned: 'websockets_02' with pid 3752
2023-12-04 22:13:35,868 INFO spawned: 'websockets_03' with pid 3753
2023-12-04 22:13:36,951 INFO success: websockets_00 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2023-12-04 22:13:36,954 INFO success: websockets_01 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2023-12-04 22:13:36,969 INFO success: websockets_02 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2023-12-04 22:13:36,989 INFO success: websockets_03 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

2 つ目のウィンドウで SSH して、次のコマンドで対話モードで(同じ OpenWrt ルーターから)サーバーにアクセスする:


python -m websockets ws://localhost:8080
> hello
< echo: hello
> yes!
< echo: yes!

対話モードを強制終了してから、ls で /var/run を見てみると、狙い通りに、*.sock がここに配置されていることが確認できる:


ls -all /var/run
(...)
srw-rw-rw-    1 root     root             0 Dec  4 22:27 websockets_00.sock
srw-rw-rw-    1 root     root             0 Dec  4 22:27 websockets_01.sock
srw-rw-rw-    1 root     root             0 Dec  4 22:27 websockets_02.sock
srw-rw-rw-    1 root     root             0 Dec  4 22:27 websockets_03.sock

NGINX を使って Web からアクセスできるようにする

前述の Supervisor を使った例では、OpenWrt 上からサーバーにアクセスしてテストした。ここでは、NGINX を使って、Web から OpenWrt ルーターにアクセスして、エコー動作させるようにする。

前述の例では、/var/run/websockets_00~04.sock が UNIX ソケットとして使われたから、それを NGINX 側で設定すればよい。

/etc/nginx/init.d/websockets.conf:


server {
    listen 8080;

    location / {
        proxy_http_version 1.1;
        proxy_pass http://websocket;
        proxy_set_header Connection $http_connection;
        proxy_set_header Upgrade $http_upgrade;
    }
}

upstream websocket {
    least_conn;
    server unix:/var/run/websockets_00.sock;
    server unix:/var/run/websockets_01.sock;
    server unix:/var/run/websockets_02.sock;
    server unix:/var/run/websockets_03.sock;
}

クライアント PC 用の Python プログラム

OpenWrt ルーターの IP アドレスは 192.168.1.1 とする(send.py):


#!/usr/bin/env python

import asyncio
import websockets

import sys

async def hello():
    async with websockets.connect(uri = 'ws://192.168.1.1:8080') as websocket:
        await websocket.send(sys.argv[1])
        message = await websocket.recv()
        print(f'send("{sys.argv[1]}") -> received("{message}")')

asyncio.run(hello())

引数をメッセージとして送るので、次のようにして使う:


./send.py hello!
send("hello!") -> received("echo: hello!")

ブート時における supervisord の自動起動設定

以上のやり方で正常に動作することが確認できたら、あとは、supervisord がブート時に自動起動されるように設定する。これは純粋に OpenWrt の話になる。

/etc/init.d/supervisord:


#!/bin/sh /etc/rc.common

START=79

USE_PROCD=1

start_service() {
	procd_open_instance
	procd_set_param command /usr/bin/supervisord -c /www/ws/supervisord.conf -n
	procd_set_param file /www/ws/supervisord.conf
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_set_param respawn
	procd_set_param pidfile /var/run/supervisord.pid
	procd_set_param term_timeout 10
	procd_close_instance
}

START=79 の 79 は、uwsgi の場合と同じにした。nginx の 80 の直前の順番になっている。

/etc/init.d のスクリプトによって、service コマンドで start/stop/restart/reload 等が可能になるが、さらに OpenWrt ブート時に自動で start されるようにするには、/etc/rc.d にシンボリックリンクを作成する必要があるが、これは enable コマンドで可能である:


/etc/init.d/supervisord enable

reboot して正常に機能しているか確認する。

LuCI のプロセス情報を見ても、狙い通りに子プロセスが 4 つ存在していることが確認できる:


さらに、wss 化する

公式チュートリアルでは説明されておらず、丸一日使って調べて、どうにか正解に辿り着いた。

  • サーバーアプリ(app.py)には変更なし。NGINX との間は unix ソケットによる OpenWrt ルーター内部での通信となるので、セキュア化の必要がないため。
  • NGINX 用の設定ファイル(/etc/nginx/init.d/websockets.conf)は SSL 用の設定に変える
  • クライアントアプリ(send.py)は wss 用に変える。

/etc/nginx/init.d/websockets.conf:


server {
    listen 8080 ssl;

    ssl_certificate     /etc/nginx/conf.d/_lan.crt;
    ssl_certificate_key /etc/nginx/conf.d/_lan.key;

    location / {
        proxy_http_version 1.1;
        proxy_pass http://websocket;
        proxy_set_header Connection $http_connection;
        proxy_set_header Upgrade $http_upgrade;
    }
}

upstream websocket {
    least_conn;
    server unix:/var/run/websockets_00.sock;
    server unix:/var/run/websockets_01.sock;
    server unix:/var/run/websockets_02.sock;
    server unix:/var/run/websockets_03.sock;
}

_lan.crt と _lan.key は、OpenWrt の LuCI 用のもの(自己署名証明書)をそのまま流用して使っている。実際に Web に公開して運用する場合には、そのドメイン用に用意した証明書とその暗号鍵を使うことになる。

クライアント側(send.py):


#!/usr/bin/env python

import asyncio
import websockets
import ssl
import sys

async def hello():
    async with websockets.connect(uri = 'wss://192.168.1.1:8080', ssl = ssl._create_unverified_context()) as websocket:
        await websocket.send(sys.argv[1])
        message = await websocket.recv()
        print(f'send("{sys.argv[1]}") -> received("{message}")')

asyncio.run(hello())

uri の ws:// を wss:// に変更している。ssl はテスト用としては、ssl._create_unverified_context() という一種の隠しメソッドを使っているのがポイントである。サーバー側の証明書が自己署名のものなので、通常ではクライアント側で証明書エラーになってしまい、信用ができないということで、接続を拒否してしまう。今は wss プロトコルで正常に通信できるようになっているかのテストなので、証明書の正当性を検証しないために ssl._create_unverified_context() を使う。本番ではもちろん、ちゃんとした ssl_context を使うこと。

これにより、無事、wss でのセキュアーな websocket 通信が NGINX との間で確立されたことを確認できた。

ちなみに、クライアント側に JavaScript の HTML 文書を使ってアクセスする場合には、ws:// を wss:// に変更するだけである。自己証明書のせいでエラーになる場合には、そのサーバー側アドレスに対して https:// でアクセスしてみて、ブラウザーが閲覧に警告を発するのを了承して先に進むことで、その自己証明書を受諾することになるので、以後 wss:// に対するエラーが解消されるようになる。

コメント

このブログの人気の投稿

シークエンスパパともの先見の明

清水俊史『上座部仏教における聖典論の研究』

シークエンスパパとも 本物の霊能力