サーバー on RaspberryPi を外部に公開

ラズパイを買ってスクレイピングやテスト環境として稼働させていたが、家庭内LANからしか利用できないのをもったいなく感じていた。 そこで何とかLAN外部からのアクセスを無料で実現する方法の調査結果および数日間の奮闘記を記す。 手っ取り早く実装したい方はリポジトリへどうぞ。

没案

固定グローバルIPの取得

なぜLAN外部からアクセスできないといえば、それは端末にグローバルIPが割り当てられていないからだ。 しかし、グローバルIPをトップの管轄から個人で取得するのは難しく、基本的にISP(Internet Service Provider)と特別なプランを契約して固定グローバルIPを割り当ててもらうことになる。

インターネット契約は私の管轄外であるため、この案はすぐに没になった。

DDNSに登録

固定グローバルIPが取得できなくても、ISPが自動的に割り当てたLANルータのWAN側IPをDDNSに登録して定期的に更新すれば、ドメインを使って外部からアクセスすることができる。 もちろんルーター側でNATやIPマスカレード設定してやる必要があるが、ルーターの設定権限があればできないこともない。 しかし私はルーターの設定権限も持っていなかったので没。LANに外部から穴を空けるため、セキュリティ的にも不安が残る案である。 そもそも集合住宅に住んでいるため、ルーターが二重に噛んでいる可能性が高く、LANルーターの設定をしたところで無駄に終わったかもしれない。

VPN

OpenVPNを活用すれば自力でVPNを構築できなくもない。 結局VPNサーバを設置するのに費用が掛かるので没。

WEBホスティングサービス

一応無料で借りられるサーバーは存在する。 もはやラズパイを活用することを諦めかけているが、やはりデータは自分の管理下に置いておきたいので没。

Remote.it

(おそらく)ポートフォワーディングを活用してネットワークを構築できるサービス。 端末のポートを開放しないためポートスキャンされる心配がない。 個人利用であれば5つの端末まで無料でネットワーク構築できる。 IoT関連で似たようなサービスは他にも見つかる。

導入は簡単なほうだが、それでも初めての利用ならばセットアップに数時間はかかる。 一度セットアップしてしまえば、デスクトップアプリ/CLIからLAN外端末に簡単にアクセスできる良サービス。

しかし、不特定多数へのWEB公開には向かないため没。

VPS

先ほどのRemote.itホストをVPS上で動かして、LANとVPSをRemote.itで繋げば間接的に公開できるのでは? 予算$3.0で格安のVPSを探しまくって1年契約のプランを見つけたが、やっぱり1セントも払いたくなくて没。

自作アプリケーション

先ほどのVPS+Remote.itの案から自分でアプリケーションを作ることを思いつく。 要はインターネットからアクセスできる環境を無料で探して、それをLAN内端末と繋げばよいのである。

クラウドサービス

クラウドサービスは山ほど存在するが、クレジットカード番号を入力したくなかったのでGCPAWS、Azureは没。 IBMクラウドは支払先を登録する必要はないが、永久無料のクラウドコンピューティング環境は存在しない。

良さそうなのが、無料枠で月500時間ほどWEBサービスを動かせるHeroku。 Dynoが実質コンテナなので、ローカルで動作しているDockerコンテナをCLIでデプロイできる(らしい)。

ということでHeroku Dynoでデプロイする(予定)。

アプリケーション

モデル

ルーターによって外部からの通信開始は遮断されているため端末を直接サーバーとしては使えないが、 今こうしてWEBページを閲覧しているようにクライアントから通信を開始すればルーターによって通信路が確保される。 そこで、

  1. DynoにRaspberryPiから接続することで通信路を確保
  2. Dynoに流れてきたユーザーのリクエストをその通信路を使ってRaspberryPiに転送
  3. RaspberryPiがローカルウェブサーバにリクエス
  4. RaspberryPiが受け取ったレスポンスをDynoに送信
  5. Dynoがユーザーに返信

通信路にHTTPを使用

実装しやすいHTTPが初期案。 サーバ側はFastAPI+Uvicornで非同期処理を行い、クライアント側はrequestsで実装する。

サーバAppはユーザーからリクエストがあるとパスやヘッダ、クッキーをクライアントAppに送信し、 クライアントAppはその情報を使ってローカルウェブサーバにHTTPリクエストする。

実装は上手くいったが、1ページの表示に30秒以上かかるため没。 HTTPは通信コストが高い。

FastAPIの宣伝

最近ウェブアプリケーション作るときはFastAPIがお気に入り。
採用の決定打は型ヒントと自動ドキュメントと非同期処理。
実体はStarlattePydanticを組み合わせたもの。
DjangoフルスタックだからRESTful APIには向かくて、Flaskは型ヒントがない(推測)からダメ。 同じ開発者が管理しているSQLModelもオススメ。

通信路にWebSocket

WebSocketは比較的新しいウェブプロトコルで、リアルタイムの画面更新などに使われる。 セッション開始時にのみHTTPを利用するため、定期的にHTTPでポーリングするAjaxなどと比べて通信コストが軽い。

FastAPIがWebSocketをサポートしていたため、ドキュメントを読みながら数時間で実装。 ページが数秒で表示されるようになり満足。

Cookieが上手く機能しない

FastAPIのレスポンスヘッダにSet-CookieをセットしてもブラウザにCookieが保存されない。(1つのレスポンスに複数Cookieを設定しても一つだけしかブラウザに保存されない?FastAPIに詳しい方がいたら教えてください!) ログイン機能などが機能せず無限ログイン地獄に嵌る。

Cookieについて調査しまくってメチャクチャ詳しくなった(気がする)。 が、問題は一向に解決しなまま時間だけが過ぎていった。

バイト列の直流し

間接的なリクエストは諦めて、Dynoに流れてきたバイト列をそのままローカルウェブサーバに転送することにした。 実装する上で参考にしたのがプロキシサーバである。

本来ならばより低レイヤーなソケットとかを扱うんだけど、Pythonはそこら辺のサポートが良くてasyncio.streamsモジュールで非同期TCPサーバを実装できる。

ホストAppとクライアントAppの通信は上手くいくんだけど、ブラウザに表示されない。 Googleしても理由がわからず。 数時間挙動を観察してると、どうやらブラウザがEoFを送信しないために永遠にストリームから読み込もうとしていたらしい。ページリロード等でブラウザから切断されるとホスト・クライアント間で通信が始まってローカルウェブサーバにアクセスできていたため正常に動作していると錯覚していた。

# 修正前のコールバック関数
def client_connected_callback(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    data: bytes = await reader.read()  # ここで無限に待機していた
    do_something()  # websocketで転送する処理

# 修正後
def client_connected_callback(reader: asyncio.StreanReader, writer: asyncio.StreanWriter) -> None:
    buffer_size: int = 4096
    data: bytes = b''
    while True:
        packet: bytes = await reader.read(buffer_size)
        data += packet
        if len(packet) < buffer_size:
            break    
    do_something() # websocketで転送する処理

リポジトリ

github.com

まだ適当に書いただけなのでロギングとかエラーハンドリングとか不十分です。

おわりに

まあ何とかこれで動作確認(総所要時間丸二日)は上手くいったので、あとは

  1. 清書してライブラリとして仕上げて
  2. Dockerイメージを作成して
  3. Herokuにデプロイして
  4. WEBページを作る

だけかな。

古典的な攻撃法は勉強して、現在サイバー攻撃対策の基礎を勉強中だけど、セキュリティのプロから見たら危ない実装かもしれないね。 (over SSLプロトコル使うのは当たり前として)

まあどうせ個人的なブログとかのホスティングに使うだけだし、いつでもリセットできるラズパイだし、LAN内で感染するマルウェアを実行されない限り大丈夫でしょ(適当)。

このブログも近いうちに消えるかも?