hnwの日記

Hono上にストレージレスなログインセッション管理を実装してみた

セッションストレージなしでログインセッションを維持する仕組みを作ったので、簡単に紹介します。

先日oidc-authというHonoミドルウェアを実装して3rd-party middlewareとして採用していただきました。これは外部IDプロバイダーで認証を行ない、自前発行したJWTを毎リクエスト検証することで、サーバ側でセッションIDを記録することなくログインセッションを維持するものです。

このセッションストレージ不要という特徴はCDNエッジと親和性が高く、たとえばCloudflare Pagesで提供する静的コンテンツにGoogle認証をつける、といったことをエッジのCPUだけで実現できます。加えて、HonoのポータビリティのおかげでDeno Deployでも同じ仕組みが使えたりします。

個人的には実用性とセキュリティを両立した面白いものが作れたと考えていますが、セキュリティ面で不安を感じる人もいると思うので解説記事を書いてみます。

エッジコンピューティングとは

本題に入る前に、エッジコンピューティングについて簡単に紹介します。

Webの文脈でエッジコンピューティングと言った場合、ごく短時間だけ動くような処理をCDNエッジサーバなどユーザーの近所で動作させる仕組みを指すと思います。Cloudflare WorkersLambda@EdgeVercelなどがその代表格と言えるでしょう。

こうした環境の動作環境としてJavaScriptエンジンが多く採用されているため、エッジ上で動かすスクリプトは基本的にJavaScriptやTypeScriptで記述することになります1

ユースケースとしては簡単な処理を前提としていることが多く、どの環境でも実行時間の上限は厳しめです。例えばCloudflare Workersは実行時間の上限が50ms2です。

やや古い文書ですが、@yusukebeさんの「CDNのエッジで実行する系が面白い」を読むと背景やユースケースをより詳しく理解できると思います。

このエッジコンピューティングですが、特にWebフロントエンド界隈の方々に支持されている印象があります。私は門外漢なので想像になりますが、利用言語の親和性と実際のニーズと両方が噛み合っているということなのでしょう。特に、エッジでSSRした結果を一定期間キャッシュしておくような使い方はメリットが大きそうです。

Honoの紹介とoidc-authの利用例

Honoは@yusukebeさんが作られているTypeScript製のWebアプリケーションフレームワークです。

Honoの特徴を一言で説明すると、「事業者ごとの差分が大きいエッジ処理にポータビリティを提供するエッジ用フレームワーク」となります3。各社のエッジコンピューティング環境は基本的にJavaScript/TypeScriptで記述するものが多いのですが、それぞれの差分がかなり大きく、複数社の環境で同内容の処理を書くだけでも意外と苦労する印象です。Honoで書くことで小さい変更で別の環境でも動かすことができます。

論より証拠で、今回私が作成したoidc-authを使って静的ページにOpenID Connectで認証・認可をつけるコードを見てみましょう。Cloudflare Pagesの場合は下記のようになります。

import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.get('*', async (c) => {
  const response = await c.env.ASSETS.fetch(c.req.raw);
  // clone the response to return a response with modifiable headers
  const newResponse = new Response(response.body, response)
  return newResponse
});

export default app

認証用のURL、クライアントIDやシークレットなどは環境変数経由で渡しています。上記の場合であればGoogle認証を経由して@gmail.comのアドレスだった場合だけ静的ページを閲覧できます。

次にDeno Deploy版を見てみましょう。

import { Hono } from 'npm:hono'
import { serveStatic } from 'npm:hono/deno'
import { oidcAuthMiddleware, getAuth } from 'npm:@hono/oidc-auth';

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.use('*', serveStatic({ root: 'public/' }))

Deno.serve(app.fetch)

異なる環境で似たコードが動くことがわかると思います。

実際の挙動やソースコードを確認したい方は下記リンクから試してみてください。

ご自身で動作確認したい場合、環境変数を最低5つ設定した上でIDプロバイダ側にコールバックURLを登録する必要があります。詳細は@hono/oidc-authをご確認ください。

JWTによるセッション管理の実装

ようやく本題です。oidc-authの実装を紹介します。

oidc-authのシーケンス図

シーケンス図を見ると少し複雑に見えますが、IDプロバイダからIDトークンを取り出すところまでは普通のOpenID Connectの認証フローです。

その後、取り出したIDトークンを元に自前でJWTを作ります。このJWTにはユーザーID4、eメールアドレス、リフレッシュトークン、リフレッシュ期限(デフォルト15分)とセッション期限(デフォルト1日)が含まれており、HS256で署名しています。

このJWTをSet-Cookieヘッダでブラウザに返し、ブラウザから送信されるJWTを毎リクエスト検証します。JWTの署名検証をパスしてJWT内のセッション期限を経過していなければセッションが有効ということになります。

上記の説明から、いわゆるセッションストレージを使っていないのがわかるかと思います。本来ならセッションストレージにおくべき情報を署名付きでブラウザのクッキーに保存し、毎回署名検証を行なっているわけです。

セキュリティにも配慮して実装しています。oidc-authではoauth4webapiというブラウザ向けのOAuthクライアント実装を利用しており、OpenID Connectの実装で必要とされるセキュリティ対策は全て実施しています5。また、クッキーのSecure属性やHttpOnly属性もちゃんと指定しています。

JWTをセッションとして使うことの是非について

5年ほど前に、SPAなどの文脈でJWTをセッション管理に使うのがいいのか悪いのかが議論になったことがあります。たとえば「どうしてリスクアセスメントせずに JWT をセッションに使っちゃうわけ?」などを読むと当時の議論がわかります。

私の理解では、セッション管理にJWTを使うべきじゃない派の主張は次のようにまとめられます。

  1. 万一JWTが漏洩したときにセッションを無効化する手段がない、JWTの有効期限切れを待つしかない
  2. ログアウト時に当該JWTを無効化できない(そういう実装が多い)
  3. 内部犯がJWT署名用の共通鍵を悪用すると任意のJWTを作って認証を回避できる
  4. JWTは巨大かつ検証コストが高いのでネットワークとCPUの無駄遣いである
  5. JWTを採用するとセッションID方式より複雑度が上がってバグを作りやすい

これらの指摘のうち、特に最初のものは致命的な問題点と言えます。一定規模の組織での運用を考えた場合、利用者がPCを紛失してJWTが悪意ある第三者に渡るシナリオは現実的な脅威です。一方、JWTは署名検証をパスすればJWTの中身を信用する仕組みなので、JWTが漏洩した場合でも管理者が無効化できないのです。

この問題に対応するため、今回実装したoidc-authではJWT内にリフレッシュトークンを格納し、比較的短いスパン(デフォルト15分)で暗黙的にトークン再発行を行っています。JWT漏洩の恐れがある場合、管理者が当該ユーザーのリフレッシュトークンを無効化すれば15分以内にアクセスできなくなるというわけです。管理者がリフレッシュトークンを無効化できるかどうかはIDプロバイダーによると思いますが、Google Workspace、Auth0、AWS Cognitoなどでは無効化可能です。

また、2つ目の指摘点への対応としてログアウト時にJWTに含まれるリフレッシュトークンを無効化しています6。これにより、JWTが仮に漏洩したとしても次回のトークンリフレッシュに失敗してJWT自体が無効になる仕組みになっています。すでに説明した通りセッション無効化を試みてから一定期間(デフォルト15分)のタイムラグを許容する前提なので、タイムラグ1分でも許せない場合はこの仕組み自体を採用できないことになります。

3つ目の指摘点も深刻な内容だと私は考えています。対策としては定期的に鍵をローテートするくらいしかないと思いますが、現状だと運用者が頑張るしかないので、oidc-auth側でも何か仕組み化したいところです。

4つ目は大変ごもっともだと思うのですが、エッジコンピューティング環境だとネットワーク転送量とエッジのCPUは安価なリソースであり、セッションストレージは相対的に高価なので許してほしい、というのが私の主張です。

5つ目も本当にその通りで、セキュリティの専門性が低い小予算のチームがJWTを使ったセッション管理を自前実装するのは一般論として危険だと思います。とはいえ、今回私は一定以上検討して作ったつもりですし、OSSの形でみんなで叩いていけば安全なものができるのではないでしょうか。そうしたノウハウを蓄積できる環境としてHonoは素晴らしいプラットフォームだと思っています。

まとめ

OpenID Connectのログインセッション維持をブラウザクッキーだけで実現するようなHonoのミドルウェアoidc-authを実装しました。CDNエッジと相性が良く、安価に認証・認可を実現できて便利だと思います。またセキュリティについても考慮しているつもりです。

とはいえoidc-authにどの程度のニーズがあるか作者自身もよくわかっていませんので、使ってみての感想やご意見をいただけると嬉しいです。

最後に、(おそらく得体のしれない状態で)oidc-authをHonoの3rd-party middlewareとして採用いただいた@yusukebeさんに感謝いたします。この記事を元にREADMEに追記して、もう少し安心して使える状態にしたいと思います。


  1. 環境によってはRustなどで記述してWebAssemblyで動作させることもできますが、最有力の選択肢とは言えない気がします
  2. フリープランだと10msとさらに制約が厳しくなります。また、50msより長時間使える別プランも提供されています。
  3. あくまで私の解釈なので、違った利点に着目しているユーザーも多いと思います
  4. IDトークンのsubフィールド
  5. oauth4webapi自体が利用者も多く継続的にメンテナンスされているライブラリで、OpenID Connectで必須とされるstateパラメータ、nonceパラメータ、code_challengeパラメータの3つの検証を標準でサポートしています。また、alg:noneを拒否するなどセキュリティ観点のベストプラクティスが実装されているように思います。
  6. 本稿サンプルコードやシーケンス図ではログアウト処理を省略していますが、ちゃんと実装してあります

ダイキン製エアコンのリモコンホルダーを3Dプリンタで自作した

筆者は自宅のダイキン製エアコンのリモコンホルダーを3Dプリンタで自作しました。これでリモコンが行方不明になる生活とはおさらばです。

壁にリモコンがつきました

このSTLファイルはThingiverseにアップロードしてあります(Wall mounted Daikin AC remote control holder by hnw - Thingiverse)。必要な方は各自プリントしてみてください。

ニッチな話題ですが、同じ悩みを持っている人向けにもう少し説明します。

ダイキン製エアコンはリモコンホルダーが別売で困った

筆者は最近引っ越したんですが、引っ越し先の備え付けエアコンがダイキン製でした。

引っ越してから気づいたんですが、ダイキンってエアコンの壁掛けリモコンホルダーが別売なんですね。一方で筆者はリモコンを行方不明にする特技を持っているので、リモコンが定位置にないと本当に不便なんですよ。

問題解決のため別売のリモコンホルダーを買おうかとも思ったんですが、純正のリモコンホルダーは壁にネジ止めする前提だったので買うのをやめました。物件の賃貸契約のルールとして壁に穴を開けちゃダメなんです。

八方塞がりの状況ですね。同じ悩みを抱えている人が日本中に数百人くらいいるんじゃないでしょうか。

他人の作ったリモコンホルダーの設計図を探してみる

今回のような実生活のニッチな悩みを解決する上で、3Dプリンタは非常に便利な道具です。任意の形状のプラスチック製パーツを実用性のある強度で作成できます。

また、大抵の悩みは世界中の誰かが既に解決しているので、欲しいものがあれば設計図(STLファイル)を探してみるのが良いでしょう。うまくいけば他人の設計図を3Dプリントするだけで問題解決というわけです。

実際、「daikin remote holder 3d」などで検索するとSTLファイルが何個か見つかるんですが、残念ながら筆者のニーズに合うものは見つかりませんでした。

筆者のニーズは次の通りです。

  • ホルダーを両面テープで壁に固定できる
  • リモコンを壁にかけたままでリモコン操作が可能
  • 形状は箱型ではなくフック

3番目のフック形状というのを補足すると、対象のリモコンは裏側にフックで引っ掛けるための凹みがあるんですね。ここで引っ掛けて使いたい、ということです。

リモコン裏側に引っ掛けるための凹みがある

なければ作ろう!リモコンホルダーを自作

他人の設計図が見つからなかった場合には自分で3Dモデリングすることになります。とはいえ、必ずしもゼロから作る必要はありません。他人の作品を改造して作ることもできます。

筆者も惜しい作品を改造して自分の欲しいものを作ってみました。筆者はモデリング素人なのでゼロから作るのは厳しいんですが、改造なら雰囲気で何とかなりました。

左:オリジナル作品*1 / 右:筆者の作った改造版

底面が狭いのでプリント難易度が少し高いかもしれません。ご注意ください。

使ってみての感想

試行錯誤の甲斐あって、自分のニーズにマッチしたものができました。レビューを見ると純正ホルダーでもリモコンが壁から浮いてしまうようなので、純正品より実用性が高い気がします。ダイキン製エアコンをお持ちの方は是非お試しください。

3Dプリントしたホルダーを壁に固定する両面テープは以下の製品が便利です。

*1:New Daikin AC remote wall hanger by gmarcell / CC-BY

自宅のPC環境を改善したら四十肩が治った話

コロナの影響もあり、この3年ほどで在宅勤務の会社さんが多くなった印象があります。 それに伴い、自宅のPC環境を改善した人って多いんじゃないでしょうか。 ご多分に漏れず私もPC環境改善を行いまして、下記のような環境が普段使いの環境になりました。

筆者のメインPC環境

一見ありふれた環境に見えるかもしれませんが、私はこの環境を手に入れてから、それまで悩んでいた四十肩の症状が大きく改善しました。

本稿では、この環境に至った経緯と購入した製品を紹介していきます。同じ悩みを持つ方はもちろん、四十肩・五十肩予備軍の方も参考にして頂ければと思います。

キーボード環境の紹介

上記の写真だとわかる人にしかわからないと思うので、実際の使い方を紹介します。

通常ポジション

コンパクトキーボード(HHKB)2台を1つのPCに接続し、左右分割キーボードのように利用しています。

トラックボール使用時ポジション

マウスポインターを動かしたい時は真ん中のトラックボールを使います。トラックボールの位置が異常に見えるかもしれませんが、慣れると非常に使いやすいです。

また、リストレストと自作のキーボードカバーにより、ホームポジションに戻りやすいように工夫しています。

ちなみにHHKBを2台買うと相当なお値段になるんですが、当時の私は四十肩解消のためなら全然惜しくないと思っていました。それくらい四十肩がストレスだったんですよね。

四十肩とは

四十肩、名前だけは聞いたことがある人が多いと思うんですが、どういう状態かピンとくる人は多くないのではないでしょうか。

私の理解では、四十肩とは肩関節の可動域が狭くなり、可動域ギリギリ付近で肩の痛みが出てくるような症状の総称です。 最初のうちは肩が上がりにくいな?くらいの感覚なんですが、悪化してくると高い棚のものを取ったりYシャツに着替えたりするたびに痛みで泣きそうになります。 また、悪い方の肩を下にして寝返りを打つと痛いというのも典型的症状です。このレベルまで悪化するとよく眠れなくてつらいんですよね。

私は在宅勤務開始後に四十肩を発症してどんどん悪くなっていきました。 今にして思えば在宅勤務になって机や椅子が変わったことで姿勢が悪くなり肩への負担が大きくなっていたんだと思いますが、当初は自宅環境に問題があるとは夢にも思いませんでした。今までも土日に同じ環境で作業してきたので、むしろ良い環境だという思い込みがあったかもしれません。

その後、かなり悪化してから病院にかかりましたが中々良くならず、藁にもすがる思いで作業環境の改善に投資してみたところ目に見えて四十肩が改善したというわけです。

おそらく、私の場合は巻き肩がクセになっていて四十肩が悪化していたのを、肩を極端に開くポジションにすることでうまく矯正できたということでしょう。

もちろん、キーボードを変えただけで四十肩が治る保証はありません。肩に石灰ができるタイプの四十肩もあるそうで、その場合は投薬か手術でないと治らないでしょう。念のため補足でした。

机の高さ数cmの調整が重要

これも後から気づいたことですが、元々使っていた机は私の座高に対して高すぎでした。小柄な男性や女性にとって、市販の机はキーボード作業には高すぎることが多いようです。

椅子と机の適切な高さは下記サイトなどで調べられます。私の場合はもともと使っていた机が最適な高さより5cm高いことがわかりました。これも四十肩を悪化させた原因だったように思います。

私はHHKBと同時期に電動スタンディングデスクを購入しました。このデスクが0.1cm刻みで気軽に高さ調整できた点も四十肩改善に役立ったように思います。また、椅子に座る姿勢と立った姿勢とを頻繁1に切り替えることで肩を動かしやすくなる利点もありました。

まとめ

肩を開くポジションでキーボード作業を行うこと、また机の高さを適切に調整することで四十肩が改善した体験談を紹介しました。同じ悩みを抱えている方の参考になれば幸いです。

私の場合は四十肩の苦しみから逃れたい一心で高額な商品を買ってしまいましたが、ここまでお金を使わなくても良かったのかもしれません。とはいえ、HHKBも電動スタンディングデスクも良い買い物だったと個人的には感じています。

FAQ

Q1: HHKBを2台買うならセパレート型キーボードで良くない?

A1: いいと思います!ただ、私は右手で6をタイプしたい派で市販品のセパレート型キーボードだと選択肢が少なかったこと、また自作キーボード系は沼っぽいので遠慮した結果、買っても絶対ハズレのないHHKBを選択しました。

Q2: キーボードを2台使うスタイルだとCtrl+pとか打てなくない?

A2: MacだとHHKB2台が別キーボード扱いになり、2台にまたがったタイプを実現するにはキーボードカスタマイズソフトが必須です。Karabiner-Elementsを入れましょう。Windowsの場合は何もしなくても動くらしいです。

Q3: 写真に写ってるMacBook ProMac mini、どっちを使ってるの?

A3: MacBook Proだけが会社の貸与品で、業務時間かどうかで使い分けています。KVM切り替え機だとUSB Type-C 2入力に対応できないのでHHKBは都度差し替えています。都度差し替えるのはイケてないですね…

Q4: ディスプレイは何を使ってるの?

A4: Acerの34インチUWQHD(3440×1440)ディスプレイをエルゴトロンのモニターアームで吊っています。


  1. 平均すると1日に10回くらいは立ったり座ったり切り替えている気がします

PHPerKaigi2022でPHPからGoogle Assistantを使う話をしました

かなり時間が空いてしまいましたが、先月行われたPHPerKaigi 2022にて、「PHPerだってPHPから「OKグーグル」したい!」というタイトルで発表しました。

発表の内容としてはPHPからgRPCを使ってGoogle Assistant APIを叩くというものでした。プレゼンの最後にPHPから「全部消して」という命令を投げて自分の部屋を真っ暗にするデモを実施したのですが、無事成功してウケたので満足です。

今回のコードは下記URLで公開しています。皆さんもOKグーグルしてみてください。

PHP+gRPC 仲間が少なすぎてしんどい問題

今回の発表内容は「やればできるでしょ」というレベルの話に見えると思うんですが、実は動かすまでにかなり苦労しました。gRPCなので使えそうなクラスや引数の型定義などは自動生成されるのですが、自分のやりたいことを実現するのに何をどう呼び出すかのドキュメントがなく、かなりの試行錯誤が必要でした。他の言語の実装を参考にしていたのですが、言語が変わるとインターフェースも変わってしまうので、そこに惑わされたところもあります。

そもそもPHPからGoogle Assistant APIを叩いたのは私が世界で初めてだった可能性があるようで、少なくともGitHub上では仲間は見つかりませんでした(いま検索しても見つかるのは私が上げたものだけです)。

また、PHP+gRPCでSSL接続する方法を調べていたら、半年前まで不可能だったのを修正したよ!という記事を発見して衝撃を受けました。皆さんSSLなしでどうやって利用してたんですか?というレベルの話に見えます。

note.com

そもそもPHP+gRPCの記事自体ネット上であまり見ないんですよね。PHPについていうとgRPCクライアントは作れるけどgRPCサーバは作れない言語1なので、PHPユーザーの中でgRPCが流行ってないというのはありそうですが、それにしたって情報が少なすぎる印象です。

PHP+gRPCを利用している会社さんはそれぞれ工夫や苦労があると思いますので、小ネタでも公開して頂けると界隈も盛り上がるように思います。

gRPCの所感

私はgRPC自体初体験だったので、その所感も少し紹介します。

gRPCの特徴的な点は、各言語のライブラリ・モジュールレベルで自動生成が行われる点だと感じました。

たとえばGoの場合はgRPCの自動生成コードをGitリポジトリとして共有すればそのままライブラリとして利用することができます(参照:https://github.com/googleapis/go-genproto)。大量のAPIを大人数に使って貰うような状況であれば、gRPCを採用することでライブラリのメンテナンスコストを下げられそうですね。

PHPの場合も多くのクラスが自動生成されるので、誰が書いても同じコードになりやすく、大人数開発では特にメリットが大きそうです。

その代償というわけではないでしょうが、仕組みが複雑すぎてハードルが高いように感じました。私は環境セットアップで心が折れかけました。

PHPerKaigi 2022について

今回のPHPerKaigi 2022は会場参加もできるしリモート参加もできる、ハイブリッド開催でした。私は初日は会場に行き、2日目はリモート参加していたのですが、好きな方を選べるのは参加の幅を広げられて良いですね。

発表内容も多岐にわたっていて面白いものばかりで、久々に刺激をもらえました。はせがわさんはじめ運営の皆様、本当にありがとうございました。


  1. かなり頑張ればできるという説もありますが、実用的かは疑問です

PHPerKaigi 2021でPHPの不変配列が高速かつ省メモリだという話をしました

この3/26〜3/28にPHPerKaigi 2021 という勉強会があり、私は「PHP7から不変配列がOPcacheに乗るのでKVSを置き換えられるかもしれないという話」というタイトルで発表しました。

改めて見直してみると発表タイトルちょっと何言ってるか分からないですね。言いたかったこととしては「PHP5まではPHP単体よりKVSを使った方が断然マシな状況があったけど、PHP7+OPcacheならKVSに勝てる」ということなんですが、全然伝わらないタイトルになっていましたね…。反省です。

内容としてはOPcahce有効のときに限りPHPコンパイル時に全要素を確定できる配列(不変配列)が特別扱いされて、これが高速かつ省メモリですという話を紹介しました。

本ブログの記事「PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話」の焼き直しではあるんですが、新たに調べた内容もあり、たとえば以下のグラフは新作です。

f:id:hnw:20210329004404p:plain
OPcacheとKVSの速度比較

f:id:hnw:20210329004306p:plain
OPcacheとKVSのメモリ消費の比較

今回、プレゼンした後に何回か質問されたので、それらについて補足します。

Q1: コレどういうユースケースを想定してるの?

発表した内容は実際に活用してるの?と複数回聞かれました。私の身近では近い利用法をしているプロジェクトがあるのですが、更新が低頻度で巨大なマスターデータを扱うというのは珍しいニーズかもしれません。

私の会社はスマートフォンゲームの開発をしているのですが、スマートフォンゲームではプランナー職の方が巨大なマスターデータを作ることがあるんですね。今回、39万要素の配列を例に出しましたけど、長期運営タイトルだと本当にこれくらいの規模になったりします1。さらに、こうしたデータはサーバだけでなくアプリにも組み込むこともあり、更新の頻度はそこまで高くありません。

そんなわけで、たとえばECサイトとかではあまり使い道がなさそうですが、我々の同業他社さんだと近いニーズがあるように思います。

Q2: OPcache無しのとき不変配列の扱いはどうなるの?

OPcache拡張が有効になっていない場合、全ての配列は従来通りopcode列にコンパイルされるので速度面のメリットはありません。不変配列の判定と構築はOPcacheの最適化フェーズで行われており、PHP本体のコンパイル処理では不変配列に関する特別な処理は何もないのです。

Q3: 不変配列が爆速になるのって2回目以降のアクセスでしょ?

その通りです。説明をサボってました。

1回目のアクセスはOPcacheに乗っていないので、PHPスクリプトコンパイルをしてOPcahceの最適化処理が走って共有メモリ上に配列を構築する必要があるので、かなり遅いです。本気で使うならpreloadingopcache_compile_file()の利用を検討した方がいいでしょう。

逆に2回目アクセスではキャッシュから取り出したopcode列の時点で不変配列を参照しているので、そりゃ速いよねという理屈です。

感想など

今年のPHPerKaigiはオンラインでしたが、非常に面白かったです。講演は事前収録でしたが、自分の収録を見ながらDiscordで出た質問に答えるという体験のは新鮮でした。

また、飽きさせないような仕掛けが多いのも良かったと思います。講演中にニコニコでコメントが流れてくるのも良かったですし、休み時間にDiscordで雑談がはじまったり、Zoomでアンカンファレンスが開催されていたりで、オフラインとは違った面白さを感じました。

最後になりますが、スタッフの皆様、今年もおつかれさまでした。これほどの規模になると運営は本当に大変だと思いますが、来年も期待しておりますのでよろしくお願いいたします。


  1. 縦だけでなく横にも長いことが多いです

特定ホスト名の通信だけVPN経由にするルータ設定(OpenWrt編)

自宅のルーターの設定で、普段の通信はデフォルトゲートウェイを使いたいけど、一部のホスト名の通信だけはVPNトンネルインターフェースを使いたい、という状況がまれにあります。一般的なニーズではないと思いますが、少なくとも私にはそういうニーズがありました。

IPアドレスごとに外向きインターフェースを切り替えたいのであればiptablesの設定だけで実現できます。一方で、ホスト名によってインターフェースを切り替えるのは一般的には困難です(ホスト名の解決はアプリケーション層で行われるのに対し、iptablesネットワーク層トランスポート層での処理になるため)。このような場合に、IPset経由でdnsmasqとiptablesを連携してルーティングを切り替える方法があります。本稿ではこのやり方を説明します。

OpenWrtとは

OpenWrtは組み込み用途のLinuxディストリビューションで、家庭用の有線LANルータ・Wi-Fiルータファームウェアを置き換えることで機能追加をしたりカスタマイズ性を高めたりしようというプロジェクトです。ルータの持つ基本的な機能(PPPoEやファイアウォールの設定)に加え、DDNSVPN・VLAN・QoSなどの設定がGUIから可能になります。また、ルータにsshでログインできるようになるので、トラブルの切り分けがやりやすくなるメリットもあります。自宅のネットワークで色々遊びたい人にはお勧めの選択肢です。

必要パッケージの追加

ではOpenWrtでの設定を見ていきましょう。まずは必要パッケージをインストールします。

# opkg update
# opkg install ipset kmod-ipt-ipset dnsmasq-full luci-app-mwan3

dnsmasqは最初からインストールされているものでは機能不足で、full版に差し替える必要があります。

DNS正引きのログ出力

今回のような実験的な取り組みを行う場合、動作確認やトラブル対応のためにDNSクエリをログに出しておくと便利です。

Web管理画面(LuCI)から「Network」「DHCP and DNS」「General Settings」「Log queries」をチェックするとログに全DNSクエリがログに残ります。ログは logread コマンドで確認できます。

# logread
(略)
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 query[A] android.googleapis.com from 192.168.2.101
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 forwarded android.googleapis.com to 192.168.1.1
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 reply android.googleapis.com is 172.217.24.138

IPsetのセット作成・動作確認

IPsetとは、IPアドレスやその他のネットワーク情報を高速に検索できるLinux上のオンメモリデータベースです。iptablesと組み合わせて使う前提の仕組みで、iptablesで大量のルールを扱うのに利用したり、今回のように別ツールと組み合わせて使ったりできます。

IPsetでは1つのデータベースをセットとよび、セットに対してIPアドレスやネットワーク範囲を追加・削除することができます。今回はforce_usというセットを作ります。

# ipset create force_us hash:ip

次に、/etc/config/dhcp を直接変更して、特定ホスト名をDNS正引きするとそのIPアドレスがforce_usセットに追加されるようにします。

config dnsmasq
    (略)
    list ipset '/api.example.com/api2.example.com/force_us'

このように、スラッシュ区切りで複数のホスト名を指定できます。また、ホスト名の後方一致でドメイン名を指定することもできます。

最後に dnsmasq を再起動して動作確認をします。DNS正引きした結果がセットに追加されていれば成功です。

# kdig a api.example.com +short
192.0.2.32
# ipset list force_us
Name: force_us
Type: hash:ip
Revision: 6
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 368
References: 1
Number of entries: 1
Members:
192.0.2.32

mwan3の設定

mwan3は外向きロードバランシング用のOpenWrt独自パッケージで、iptablesのラッパーです。今回のように外向きインターフェースを使い分けたいだけの場合にはオーバースペックですが、Web管理画面から設定できて楽なので利用しています。

mwan3の設定で、先ほど作ったforce_usセットにマッチするIPアドレス宛てなら別のインターフェースを使うよう設定していきましょう。

まずWeb管理画面「Network」「Load Balancing」「Interfaces」の「Add」からvpnusインターフェース(OpenVPNで設定したVPNトンネルインターフェース)を設定します。これで既存インターフェースがmwan3の管理下に置かれて死活監視が行われるようになります。

次に、「Network」「Load Balancing」「Members」からvpnus_m1_w3というメンバーを追加します。ここでインターフェースとして先ほど設定したvpnusインターフェースを指定します。

f:id:hnw:20210222230837p:plain
mwan3のmember設定

さらに「Network」「Load Balancing」「Policies」からvpnus_onlyポリシーを追加します。ここで先ほどのvpnus_m1_w3メンバーを指定します。

f:id:hnw:20210222231019p:plain
mwan3のpolicy設定

最後に「Network」「Load Balancing」「Rules」でforce_us_v4というルールを作ります。IPsetについては先ほど作成した「force_us」を指定し、「Policy assigned」は先ほど作成した「vpnus_only」ポリシーを指定します。

f:id:hnw:20210222231137p:plain
mwan3のrule設定

以上の設定で、特定ホスト宛の通信をVPN経由にすることができました。

ipsetの保存

ルータを再起動しても同じ設定を維持するため、IPsetのforce_usセットをルータ起動時に作るようにします。

/etc/config/firewallを直接変更しましょう。

config ipset
    option enabled '1'
    option name 'force_us'
    option storage 'hash'
    option family 'ipv4'
    option match 'dest_ip'

制限事項

今回の仕組みが期待通り動作するためには、通信を行う前にDNSの正引きが行われる必要があります。逆に言うと、IPアドレスでアクセスするようなサービスでは使えません。ストリーミング系サービスの中にはAPIサーバからストリーミングサーバのIPアドレスが返ってくるようなものがありますが、こうした場合には適用できません。また、端末がルータのDNSを利用していないような場合も無力です。

まとめ

OpenWrtルータ上でdnsmasqとIPsetを連携させて特定ドメイン・特定ホスト宛の通信だけをVPN経由にすることができました。1ヶ月ほど使っていますが、今のところ期待通りに動作しています。

参考URL

go-shellwordsでUnixシェル的なものを実装した

私はSlackの入力テキストに対応して外部コマンドを起動するbotをGoで自作しています。このbotに最近「&&」などUnixシェルの演算子を部分的に実装したのですが、その際go-shellwordsが便利だったという話を紹介します。

私の作っているSlack botの簡単な紹介

私の作っているSlack botは入力テキストのパターンマッチを行い対応する外部コマンドを実行するようなものです。実行例を下記に示します。

f:id:hnw:20210117185029p:plain
自作Slack botの実行例

この例では銀行サイトから情報取得するNode.jsスクリプト2つ(口座振替照会、残高照会)を連続で実行して銀行引落に必要な残高があるかを確認しています。

go-shellwordsとは

go-shellwordsはmattnさん作のGoライブラリで、コマンドライン文字列を受け取ってコマンド引数の配列を返すようなものです。

Goのexec.Command()はコマンド引数の配列を受け取る仕様なので、今回のSlack botのように引数を含むユーザー入力を受け取ってコマンドを起動する場合は引数の配列に分割する必要があります。単純なコマンドしか受け付けないのであればstring.Split()で分割してもいいのですが、Unixシェルのようなクォーティングやエスケープに対応したい場合はgo-shellwordsを使うと便利です。

また、go-shellwordsはUnixシェルの特殊文字が出現するとparseを止めてくれるので、「&&」による複数コマンド実行にも対応できます。簡易コードですが、私は次のようなコードを書きました。

   for {
        // コマンドとみなせるところまでparse
        args, err := parser.Parse(line)
        if parser.Position < 0 {
            // 文字列末尾までparseした
            return
        }
        // コマンドの実行(または直前のコマンド実行結果と演算子次第では実行しない)
        // parseできなかったところから演算子を探す
        i := parser.Position
        token := line[i:]
        operators := []string{";", "&&", "||"}
        for _, op := range operators {
            if strings.HasPrefix(token, op) {
                // 演算子を保存
                i += len(op)
                break
            }
        }
        // マッチした演算子の後ろから次のループでparseする
        line = string(line[i:])
    }

エラー処理を省いてあるとはいえ、かなりシンプルに実現できるのがわかると思います。もっとも、今回は「&&」「||」「;」の3つしか対応していないためシンプルに書けた側面もあります。リダイレクトやカッコに対応しようと思ったらもう少し真面目に処理を書く必要があるでしょう。

エラー処理を含む実際のコードに興味がある場合は私の実装(executor.go)をご覧ください。

実装する演算子が左結合だけで済むと楽

「&&」「||」「;」の3つだけでも実装大変じゃない?木構造作る必要あるよね?と考えた人がいるかもしれません。私もそう思っていたのですが、Unixシェルの場合は偶然が重なって先頭から逐次処理していくだけで実現できました。

確かに、普通のプログラミング言語であれば演算子「||」より「&&」の優先度が高いので、例えば A || B && C という論理演算は A || (B && C)と解釈する必要があり、これを実現するには木構造を作る必要がありそうです。

しかし、Unixシェルでは「&&」と「||」の優先度は同じです(参考:Bash Reference Manual 3.2.4 Lists of Commands)。「;」だけはマニュアル上で優先度が低いのですが、同じ優先度と考えても矛盾は生じないようです1

全部の演算子が左結合だと頭からループで実装できるので楽ですね、という手抜きテクニックの紹介でした。

本物のシェルを使わない理由

蛇足かとは思いますが、Unixシェルっぽいことを実現したいときに本物のUnixシェルを使うのはオススメしません。よほど注意しないと外部文字列を元に任意コマンドを実行される脆弱性(OSコマンドインジェクション)を作り込んでしまいます。

今回の処理ならexec.Command("sh","-c",line)などとすればシェルのコマンドラインのフル機能を利用できますが、line がシェルに渡して安全な文字列であることを保証するのは困難だと思います。逆の見方をすれば、Unixシェル的な機能を実現するのに本物のUnixシェルが使えないからこそgo-shellwordsが便利なわけです。

まとめ

  • Unixシェルっぽい文字列処理をするのにgo-shellwordsを使うと楽だし安全
  • シェルの演算子のうち「&&」「||」「;」だけ実現するのは意外と楽

  1. A && B ; C && D を例に考えると、(A && B) ; (C && D)(A && B ; C) && D はどちらもCが成功したときだけDを実行するわけですから等価です。他の例を考えても完全に等価だと思うのですが、数学的に示せるかはわかっていません。数学的帰納法の出番かなあ…?