nginxとLassoを使ってサイトをOpenID Connect(Auth0)で保護する

試験的にQiita版: https://qiita.com/okuoku/items/51e12289061ade156107
★ 注: Lasso自体はあんまりプロダクションに向いていない印象を受けた。全く同じことはoauth2_proxyでも可能で、oauth2_proxyのforkもいくつかあるので、そちらを検討する方が良い気がしている。
基本的にはサイト側でOpenID Connectのサポートをするのが望ましいが、nginxにはリクエストヘッダだけを一旦別のHTTPサーバに送信し、その結果によってアクセス可否を決める auth_request モジュールがあるため、HTTP認証サーバと適当なブラウザベースの認証機構を用意すれば任意のサイトを任意の認証フレームワークで保護することができる。

従来は oauth2_proxy というbit.ly製の認証プロクシがこの手のユースケースには(おもにk8s界隈で)よく使われていたが、oauth2_proxyはbit.ly側でのメンテナ不在により今月末でのプロジェクト終了が宣言( https://github.com/bitly/oauth2_proxy/issues/628#issuecomment-417121636 )されている。
認証プロクシは、バックエンドHTTPサーバへのリクエストも行う。それに対して、 auth_request による認証の移譲ではバックエンドサーバへのリクエストはnginxが行うことになる。このため、auth_requestで使う認証サーバはnginxの十分近くに配置する必要がある -- 認証プロキシに比べて、TCPなりなんなりの接続コストがそのままリクエスト遅延に上乗せされる。もっとも、認証プロキシでは事実上フロントエンド側のリバースプロキシによって2重のコンテンツバッファリングが発生してしまうので、どちらも一長一短な気はする。

今回の方法はメジャーなSSOベンダである Okta のブログに有るが、OktaでなくAuth0( https://auth0.com/ )を使ってみた。どちらも開発者向けには無料アカウントが有り、1000人までのユーザベースなら無料となっている。(Auth0は、更にFOSSと認められた場合は製品版も完全無料で使用できる -- TravisCIのようなfree-for-dev https://github.com/ripienaar/free-for-dev モデル)。
今回は、stripe.local/auth/* をLassoのための認証に使用し、stripe.local/testingというWebリソースの保護を考える。

nginxの設定

nginxの設定はほぼOktaのブログに掲載されている通りだが、リライトを使ってrootを移動している。

http {
    server {
        listen 443 ssl;
        server_name stripe.local;
        ssl_protocols TLSv1.2;

        ssl_certificate f:/serv/keys/out.cer;
        ssl_certificate_key f:/serv/keys/key.pem;

        location /testing/ { ★ ここが保護したいリソース
            auth_request /lasso-validate;
            error_page 401 = @error401;
            root f:/serv;
        }

        location @error401 { ★ Lassoから401エラーが帰ってきた場合のリダイレクト先
            return 302 https://stripe.local/auth/login?url=https://$http_host$request_uri&lasso-failcount=$auth_resp_failcount&X-Lasso-Token=$auth_resp_jwt&error=$auth_resp_err;
        }

        location /auth { ★ ブラウザからLassoにアクセスできるようにする
            rewrite /auth/(.*) /$1 break;
            proxy_set_header Host login.stripe.local;
            proxy_set_header X-Forwarded-Proto https;
            proxy_pass http://127.0.0.1:9090;
        }

        location /lasso-validate { ★ 認証の移譲先
            proxy_pass http://127.0.0.1:9090/validate;
            proxy_pass_request_body off;

            proxy_set_header Content-Length "";
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto https;
            proxy_set_header X-Forwarded-Port 443;

            auth_request_set $auth_resp_jwt $upstream_http_x_lasso_jwt; ★ リダイレクト先指定で参照できる変数のセット
            auth_request_set $auth_resp_err $upstream_http_x_lasso_err;
            auth_request_set $auth_resp_failcount $upstream_http_x_lasso_failcount;
        }

nginx は常にユーザのリクエストヘッダをLassoに転送し、LassoはCookieの内容をチェックして認証済かどうかを確認する。認証済のCookieを持っていなかったら、Lassoは401を返してnginxに上で宣言したような @error401 を実行させ、結果的にユーザは302でLassoの /login に転送される。正当なCookieを持っていたら、Lassoは200を返し、nginxはそのままコンテンツの提供を継続する。
Lassoは保護されたリソースへのリクエストを全て検証することになるので、パフォーマンスに対する要求はそれなりに高いことになる。
ここでは認証したユーザ名の処理は特に実装していない。つまりOpenID Connect認証に成功したユーザは無条件でリソースへのアクセスが許可される。Oktaのブログの セクション Bonus: Who logged in?やLassoのREADME.mdに、HTTPヘッダを経由してユーザ名を渡す方法が有る。

Auth0の設定

Auth0では適当にテナントを作成した上で Regular Web Application を作成し、Allowed callback urlsをLassoの /auth を指すように設定する。今回の場合は、 https://stripe.local/auth/auth になる(本来は /auth だけだがrootを移動しているため)。

LassoにはOpenID Connectの自動設定(discovery)機能は無いので、LassoのYAMLには諸々の設定を手動で実施する必要がある。これらのデータはAdvanced settings >> Endpoints 以下にある。

実際のユーザについては、Auth0の無料アカウントでは2種類までのソーシャルログインを有効化することができる。ソーシャルログインを使うと、ユーザの持っているGoogleとかTwitterのアカウント認証で認証を代替できる。ただし、複数のソーシャルアカウントを1つのAuth0上のユーザに結びつける機能は無料アカウントでは提供されない。(non-profitなFOSSについてはJIRA/Confluenceのように有料版をコストなしで提供している。)

Lassoの設定

Lassoの設定は、Lassoのexecutableがあるディレクトリ上の config/config.yml で行う。

lasso:
    logLevel: debug # ★ 機密情報もガンガン出るので注意
    listen: 127.0.0.1
    port: 9090
    # !!!! FIXME !!!! allowAllUsers for now.
    # Lassoは元々Googleアカウント等への統合を想定しているため、ユーザ名中のドメインを追加で検証できるようになっている
    allowAllUsers: true
    publicAccess: false

    jwt:
        issuer: Lasso
        maxAge: 240 # 240分 = 4時間は再認証しない
        secret: lZlMPLfBjcIQCjbxjEoKep5dWn6xDjyW # これは乱数で良い
        compress: true

    cookie:
        name: Lasso-cookie
        secure: true
        domain: stripe.local
        httpOnly: true
    session:
        name: lasso-session

    headers:
        jwt: X-Lasso-Token
        querystring: access_token
        redirect: X-Lasso-Requested-URI

    db:
        file: data/lasso_bolt.db # data/ ディレクトリを事前に用意しておくこと

oauth:
    provider: oidc
    # ★★ 以下のclient_idとclient_secretは本来開示してはいけない。
    client_id: TfMpW26Hah9HI5sQw0WpVoCIhCS2V5qN
    client_secret: Q9eBFIKMRkhSGjjfrSxDITHDNgV2UwnMO4KqKR5jTBCqNbSNQBfRC50fZ5ozX1fc
    auth_url: https://yuni.auth0.com/authorize
    token_url: https://yuni.auth0.com/oauth/token
    user_info_url: https://yuni.auth0.com/userinfo
    scopes:
        - openid
        - email
    callback_url: https://stripe.local/auth/auth # Auth0に設定したCallback urlに一致すること

OpenID Connect OPのなかには、callback_urlとしてローカル端末を許可していないことがある。Auth0の場合は問題なく設定できるが、プロバイダ側で許可されていない場合はhostsを書くとか何らかの方法でワークアラウンドが必要になる。
YAMLにはいくつかの機密情報を設定する。client_idとclient_secretは認証を提供するOpenID OP(= Auth0)がRP(= ここで設定したLasso)の真正性を確認するのに必要となる(プロトコル上機密情報なのはclient_secretの方)。jwtのsecretはLassoが発行するJWTの認証鍵として使われる(これをランダムにしないと、別のLassoインスタンスが同様にJWTを発行できてしまう)。

OpenID Connectはユニバーサル認証手段になれるか?

というわけで、auth_requestを使うと任意のブラウザ認証手法をWebサイトに統合できることがわかった。もっとも、これがどの程度役に立つかは何とも言えない。
明確に役に立つのは、本来認証の無いWebサイト -- 例えば内部で作っているダッシュボードとか -- にそのまま認証を付加できるポイントだろう。ただ、他の認証手段(BASIC認証やアプリケーション組込の認証)に比べると:

  • Deployが大変。通常の ID/パスワード認証であればBasic認証で良いし、LDAPを認証基盤としていて、かつOktaのようなOpenID Connectプロバイダを採用していないなら(あるいは自由にアプリケーションを追加できる環境でないなら)dex https://github.com/dexidp/dex とかKeyclockのようなOpenID Connect実装を別途用意しておく必要がある。
  • アクセス権の扱いが微妙。グループ等のclaimを伝播させる良い方法が無い。有料版のnginxにはJWTのパース処理 http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html が存在するのでnginx内での処理も可能だが。。
  • ユーザサービスを提供する余地が無い。そもそもアプリケーション側で明示的に対応しないと "ログアウト" みたいなリンクを置く場所が無い。
  • 複数サービスの統合が難しそう。今回のセットアップでは、Lassoは / に認証クッキーを設定する。このため複数の異なる認証機構を単一ドメインでサービスするためには追加の考察が必要になる。

個人的には、HTTP経由のWindows統合認証が上手く動いた試しが無いので、ID/パスワードを前提とした実装よりは、OpenID Connectベースの認証に寄せた方がSSOや2FAの実装の上では有利な気はしている。...でも自作アプリの割合が多い現状を考えるとアプリ側に認証を用意した方がUI的には便利というか何というか。。

ssh port forwardingとstone経由でLet's encryptのワイルドカード証明書を作成する

Let's encryptではワイルドカード証明書を発行してもらうこともできるが、これにはDNS認証が必須となっている。
...このためだけに動的なDNSサーバを用意するのもちょっと面倒なので、グローバルIPv4アドレスを持つVPSUDP port 53のパケットを転送させ、ローカルに立てたDNSサーバを使ってDNS認証を通してみた。
要するに、"静的なグローバルIPv4アドレスが割り当てられ、root権限を持っているsshでログインできてCコードをコンパイルできるホスト"と、"NSレコードをそのホストに向けられるドメイン"を持っていれば、PC上にDNSサーバを構築してそこにワンタイムパスワードを置くことで、Let's encryptのワイルドカード証明書を入手できる。

必要なソフトは、

の3つで、SSHとstoneを使ってポート転送をすることで、VPS側でgoのビルド環境を用意する必要が無いのがポイント。

NSレコード / DNSサーバの準備

Let's encryptのDNS認証は _acme-challenge.EXAMPLE.ORG のようなDNS名のTXTレコードを引き、そこにワンタイムパスワードを格納することによって行われる ( https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-8.4 ) 。これは通常のDNS解決によってクエリされるため、他のサーバにコンテンツを移譲することができる
先のページ https://qiita.com/binzume/items/698d12779b8ad5cda423 は、まさにこの点を使用していて、普段使用しているDNSコンテンツサーバとは別の適当なDNSサーバを静的なIPv4アドレス上に配備して、必要なワンタイムパスワードをLet's encrypt側に見せている。NSレコードの書き方もこのページにある。
ただ、今回はグローバルIPv4を持つサーバにはgoのビルド環境が無いため一工夫が必要になる。

SSHとstoneを使用してDNSクエリのproxyをする

goで書かれたDNSサーバをVPS上に持っていくにはVPS上にgoの開発環境が必要になる。これはちょっと面倒なので、DNSのパケットを手元のPCにproxyしてきて、PC上にDNSサーバを立てることで代えたい。ここでは、インターネット上のUDP port 53トラフィックを手元のPCのUDP port 9953に転送することを目指す。(直接53に転送しないのは、1024未満のポートにbindするにはroot権限が要るため。)
DNSプロトコルUDP上に実装されているため、インターネットからやってきたUDPパケットを自宅のPCまで転送することができればproxyできたことになる。stoneにはUDPTCPの相互変換機能が存在する( http://www.gcd.org/blog/2007/06/121/ )ため、これとSSHTCP転送機能を組み合わせれば良い。
グローバルIPv4を持つホストでは "stone localhost:9953 0.0.0.0:53/udp" を実行して、0.0.0.0に対するポート53(DNS)のパケットをTCP localhost:9953 に転送させる。bindするポートが1024以下なのでsetuidなりなんなりしておく必要がある。
tmpdnsを動作させるPCではこの逆操作を行う: "stone localhost:9953/udp localhost:9953" を実行しておき、"ssh -R9953:localhost:9953" でグローバルIPv4を持つホストに接続し、stoneによってUDPTCP変換されたDNSプロトコルを転送する。
あとは普通にacme.shなりなんなりを使ってDNS認証を進めることで証明書が入手できる。

手法の安全性

というわけで、自分のドメインのNSレコードを編集でき、グローバルIPv4アドレスを持つホストのUDPポート53にアクセスすることさえできれば、Let's encryptは証明書を発行してくれることはわかった。
... コレ安全なんだろうか。。?
Let's encryptが使用しているプロトコル(ACME)のIDでは、

   o  Always querying the DNS using a DNSSEC-validating resolver
      (enhancing security for zones that are DNSSEC-enabled)

   o  Querying the DNS from multiple vantage points to address local
      attackers

   o  Applying mitigations against DNS off-path attackers, e.g., adding
      entropy to requests [I-D.vixie-dnsext-dns0x20] or only using TCP
...
   It is RECOMMENDED that the server perform DNS queries and make HTTP
   connections from various network perspectives, in order to make MitM
   attacks harder.
...
   Servers SHOULD perform DNS queries over TCP, which provides better
   resistance to some forgery attacks than DNS over UDP.

のようにいくつかの保護を考察しているが、今回のように実際のTXTレコードの取得はUDPパケット一発で完了してしまっている。もちろん、実際の証明書の発給は別の(TLS保護された)チャネルによって行われるので、攻撃を実施するにはこのTXTレコード応答を改竄しつつ、同時に証明書をリクエストする必要が有るなどそれなりのハードルは有る。
今回のやりかたが通用することでわかるように、少くともLet's encryptの実装ではACME手続きを実施するホストとDNS応答を行うホストは一致している必要がない -- そもそも現実的なシナリオでは両者に関係を求めずらい。
TCP onlyのDNSサーバで上手くいくのかは興味深いポイントだが、時間の都合で試せていない。

SSHポートフォワードとstoneで何でもできるのか

個人的には、この"グローバルIPv4アドレスを持った低機能なサーバにSSH接続してサービスする"形態はもっと色々な可能性があると思っていて、環境含めもっと追求していきたい。
ローカルのprivate環境をインターネットに公開するproxyシステムとしてpagekite( https://pagekite.net/ )のようなサービスがあるが、これをSSHのポートフォワードで実現するものとしてServeo( https://serveo.net/ )がある。pagekiteはサーバを公開するためには専用のクライアントが必要なのに対し、ServeoはSSHクライアントさえあれば良いためかなり手軽になっている。
...というか最初はこのstoneをつかったやり方を全く思いつかず、Azureを借りたり、Herokuやglitch(https://glitch.com/ - LinuxアプリのためにAWSインスタンスを無料で貸してくれるサービス)でできないか考えたり、VPNを張ったりしていた。。意外と"静的グローバルIPv4と簡単なアプリだけ"という構成のサーバは手軽なものが無く、例えばAzureだとContainer InstanceやAzure Functionsに静的IPが振れない等静的アドレスを要求にした途端にかなりハードルが上がってしまう。
日本の特殊な事情としてトラフィックが激烈に安いというポイントが有り、他所の国ではコンテナ化したサービスをアップロードして実行するものでも、日本ではサービスをローカルで実装し公開エンドポイントへのoutbound接続で済ませられる可能性がある。このトポロジの違いを、開発やデプロイの面で生かせないだろうか。
もっとも、SSHプロトコルは現実のワークロードに耐える程効率的でもないし、そもそも静的なグローバルIPv4アドレスの必要性自体が低下している。。Amazon LambdaやAzure Functionsに見られるように、アプリケーションロードバランサの下にAPIサービスを展開することで多くの機能を実現できるので、Webアプリケーションにとって自前のドメイン名や静的なIPアドレスが必要な局面はかなり小さくなっていると言える。今回ワイルドカード証明書を用意したのは、単にPWAのframeを自前のドメインで描画する必要があったためで、PWA本体はそのドメインから配信する必要性自体が無い。

BiwaSchemeでReactNativeする

というわけで、BiwaSchemeでReactNativeできるようになった。

左はMS Edgeで動作するreact-native-domで、右はWindows版のReactNative( https://github.com/Microsoft/react-native-windows )となっている。(React Nativeの方に出ている"000 000"はFPSカウンタ) ...どちらもChakraなので後でAndroidiOS(JavaScriptCore)でも試すことにする。
もっとも、まだViewとTextしか動作を確認していない https://github.com/okuoku/biwasyuni-editortest/blob/2342b33e818b7c0689208f958432b427e9c2381a/yunilib/appmain-rn.sls#L42 。通常のReactとコードを共有する上でどういう中間ライブラリを挟むのが良いのか考え中。。

"use strict"に対応する

React Nativeでは、標準のJavaScript bundlerとしてMetro( https://facebook.github.io/metro/ )を採用している。が、このMetroはJavaScriptのパースに直接Babelを使用しているため、非strictなコードを読むことができない。Babelは何もオプションを指定しないとソースコードをES6だと思ってパースすることになるが、Metro側にパーサのオプションを設定する方法がない。
通常のシチュエーションではあまり問題にはならないが、BiwaSchemeはライブラリ実装のためにstrictモードでは禁止されているwithを使用している( https://github.com/biwascheme/biwascheme/blob/2f69a3dfab57f8ec9b5c2c98702db703192bf8b7/src/library/extra_lib.js#L2 )ため、ReactNativeで直接使うことができない。
...どうしようか悩ましいところだが、今回はforkで解決することにした。つまり、withによってトップレベルにインポートすることが期待されるシンボルを全部ローカルスコープに導入してしまう( https://github.com/okuoku/biwascore/blob/d9ca63989332c758c3c7fde9b69898539aebe6bc/gen/r6rs_lib.js#L10 )ことで、withが無くても元のコードを直接実行できるようにした。
また、ついでに各拡張ライブラリはCommonJSのmoduleとして切り出している。これにより、必要なライブラリだけをアプリケーションに組込めるようになった。upstreamとのやりとりが簡単になるように、ファイル構成はそのままにしている。

未解決の問題

まだ色々と未解決の問題が残っている。まぁ本命はWebアプリ、というか実際のアプリをPWAで作るつもりなのでReactNativeは一旦置いておく。。
アセットをJS側にロードするためのプラットフォーム共通手法が無い。ReactNativeではアセットはコンポーネントレンダリングされるものという想定で、Imageのようなアセットをレンダリングするための(組込みの)コンポーネントに渡すことしかできない。今回の場合はSchemeソースコードコンポーネントではなくJavaScript側に渡したいが、ReactNativeのアセットシステムではURLしか基本的に取れない。このためHTTPサーバを前提にできるデバッグ実行時でしか動作しない -- ただしreact-native-domではそもそもブラウザで動作しているのでアセットは常にfetch() APIで取得できる。react-native-windowsの場合は、パッケージをビルドするとアセットのURLがHTTPではなく"ms-appx://"のURLになるためfetch() APIで処理させることはできない。
ロジックを外部に持っていきたい場合はTransformerとして書くことになるが、これも基本的にJavaScript言語へのコンパイルを想定しているので今回のユースケースには合わない。たとえばS式をJSONに変換するだけのTransformerを用意して実行すれば良さそうでは有るが。。
(ちなみに、本当にマジでblobをサポートする必要があるならrn-fetch-blobが有る https://github.com/joltup/rn-fetch-blob これはios/androidのために専用のプラットフォームサポートを持っている。)
ロジックをプラットフォーム毎に切り分ける方法が無い。ReactNativeはソースコードやアセットを(R6RS処理系で見られるような)拡張子ルールで切り分ける方法を採用している(hoge.windows.js を代わりに読む)。今のところyuniではプレフィックスルールを採用しているため、まるでエンディアン論争のように逆になってしまっている。yuniはそもそも対応処理系が多いのでプレフィックスルールにしないと大量のファイルを撒くことになる -- それよりは、移植層を環境毎のディレクトリに纏めた方が自然だと考えている、が、(R6RSも含め)世間的には逆というわけでちょっとどうしようか悩むところ。。
また、ReactNative自体も複雑なプラットフォーム解決ができないという問題がある(MSのプロポーサル https://github.com/facebook/metro/issues/135 )。つまり、ios/android/windowsのような1段階目の派生のみがビルドシステムではサポートされており、windowsが本来持つであろうxboxとかwpfのような派生を取り入れる余地がない。
プラットフォームの切り分けは本来:

  1. ビルド時の切り分け。React-nativeの場合最終的なパッケージはプラットフォーム間で共用されないためこちらを採用できる。
  2. 動作時の切り分け。Webで動作する場合、例えばテスト/本番とかモバイル版/デスクトップ版のような実行時の切り分けも発生する可能性がある。

のようなバリエーションが有るため仕様含めちょっと真面目に考える必要がある。。
yuniの重要なデザインチョイスはScheme界隈では比較的一般的なcond-expandを捨てたことで、ライブラリ名の差し替えのみのサポートとした。これにより環境毎のバリエーションの出方を固定することにしている。これをやり切れるかどうかはちょっとなんとも言えないが。。

BiwaSchemeでReactする


諸般の事情でMithrilではなくReactに乗り換えた。 ...単にMaterial-UI( https://material-ui.com/ )が使いたかっただけだけど、最近のReactNativeにはreact-native-domというかなりアツいプロジェクトが有り( https://github.com/vincentriemer/react-native-dom )、試してみたい気持ちがある。

ビルド済のサイトをNetlifyに置いてみた。実装内容は前回( http://d.hatena.ne.jp/mjt/20180802/p1 )と同じく、ボタンを押すとカウントアップするだけ。F12で開くコンソールにSchemeからデバッグ出力もしている。
NetlifyはGitHub pagesのようにgitリポジトリにpushすることでサイトをビルドし公開することができる。GitHub pagesより強力なのはビルドにnpmやyarnがそのまま使用できる点で、yuniのビルドも含めて(ビルド成果の中間リポジトリを用意することなく、)無料で配置できている。

そもそもBiwaSchemeでReactにインターフェースできるのか問題

BiwaSchemeのようなJavaScript言語にとって、Reactのようなここ数年のJavaScriptインフラストラクチャを前提としたプラットフォームとのアクセスは難しい問題になる。ただ、Reactはちゃんと素のJavaScriptからライブラリにアクセスする方法をドキュメントしていて:

これらを駆使すればBiwaSchemeからReactコンポーネントを定義して使うことが一応できる。
JSXが無い場合は、MithrilのようにHyperScript(HTML要素を挿入するための手続きインターフェース)を使用してコンポーネントを記述することになる。ES6クラス構文が使用できない場合は、オプションライブラリである create-react-class を使用して通常のJavaScriptオブジェクトからクラスを生成できる。

this問題

Reactのコンポーネントを記述する上で問題になったのは、BiwaSchemeはスクリプト内からthisにアクセスできないという点で、エレガントな解法が全然浮かばない。
典型的なReactのコンポーネントは、イベントハンドラ中でthis.setState()を呼び出して自身の状態を更新し、再描画をトリガする:

  handleClick() {
    this.setState(prevState => ({
      isToggleOn: !prevState.isToggleOn
    }));
  }

しかし、BiwaSchemeではScheme側のクロージャからJavaScript文脈のthisにアクセスすることができないため、何らかの方法でthisを参照するメカニズムを実装する必要がある。
今回はヘルパ手続き thiswrap をJavaScript側に実装し、クロージャにthisを引数として渡す方式を取ってみた。

var thiswrap = function(cb){
    return function(){
        return cb(this);
    };
};

Scheme側はsyntax-rules(yuniが実装している擬似的なもの http://d.hatena.ne.jp/mjt/20180521/p1)で適当にラップしてrenderやhandleClickといった手続きに設定する:

(define %thiswrap (yuni/js-import "thiswrap"))
(define-syntax wrap-this   ;; Yuni generic-runtime requires 
  (syntax-rules ()         ;; EVERY syntax-rules as top-level form
    ((_ this form ...)
     (js-call %thiswrap (js-closure (lambda (this) form ...))))))
- snip -
      (define (counter-object)
        (js-call 
          createReactClass
          (js-obj
            "render" (wrap-this this
                                (js-call e Button
                                         (js-obj "color" "primary"
                                                 "onClick" 
                                                 (js-ref this "handleClick"))
                                         (number->string
                                           (js-ref (js-ref this "state")
                                                   "count"))))

            "handleClick" (wrap-this this
                                     (count++)
                                     (PCK 'COUNT count)
                                     (js-invoke this "setState" theCounter))
            "getInitialState" (js-closure (lambda () theCounter)))))

もうちょっとクールに実装できるような気がする。。

BiwaSchemeでWebアプリを作る の2


とりあえずMithrilでイベントをScheme側に渡し、値を更新してビューをアップデートするところまで実装できた。 https://github.com/okuoku/biwasyuni-editortest/tree/175a298e14ee2e530cbb3ff197573ba8f4c92f23
リポジトリにはyuniとbiwasyuniをsubmoduleとして入れてあるので、クローンしてsubmoduleをinit、updateし、

cd biwasyuni && yarn
yarn
yarn start

でポート8080にexpressが上がるようにしている。
ONCLICKのログはScheme側から出力されている。

(define (counter-object m) ;; m はmithril.jsのHyperScriptオブジェクト
  (define counter 0)
  (define (onclick e) ;; onclickイベントハンドラ
    (PCK 'ONCLICK counter e)
    (set! counter (+ counter 1)))
  (let ((handler (js-closure onclick)))
   (js-obj "view"
           (js-closure (lambda ()  ;; m("div", {onclick: function(...)}, counter);
                         (js-call m "div.alert.alert-primary[role=alert]" 
                                  (js-obj "onclick" handler)
                                  (number->string counter)))))))

ビルドシステム

ParcelのCLIを使うのは諦め、Parcelのライブラリを使ってビルドする方式を取った。Parcelのビルドシステムはexpress互換のmiddlewareとして使用できるため、Parcelがビルドするファイル以外にSchemeソースコードをserveする都合。
Scheme側は簡易的なyuniのブートストラップを実装している。BiwaScheme自体はR6RSライブラリをサポートしていないためライブラリシステムとしてyuniを使うが、yuniのリポジトリをどうやってビルドするかが課題になる。これを機に、yuniはビルド不要で使えるように、つまり、チェックアウトしたままの状態で使えるように変更していくことにした。
現状、yuniは使用するためにbootstrap schemeいづれか(Chez、Racket、SagittariusGauche または Chibi-scheme)を使用してブートストラップを行う必要が有るが、これを不要にした方が取り回しが良いと考えられる。今回のケースだと、本来bootstrap schemeでないBiwaSchemeだけの状態でもyuniを使うことができる。

アプリケーションの記述とロード

アプリケーションはyuniのR6RS-lite形式で記述する。アプリケーションのロードは普通にHTML5のfetch APIを使用している。今のところビルドシステムにファイルの結合やminifyの類は実装していないので、ファイルは1つ1つダウンロードされる。

動的なライブラリのロードは未対応で、今のところライブラリファイルは事前にloadしておく必要がある。これはBiwaSchemeがexpand中にPauseできないという制約があるため( https://github.com/okuoku/yuni/issues/109 )。ビルドシステムは事前にどのファイルをloadすべきかを把握できるので、アプリケーションを実装する上ではそんなに障害にはならない。。はず。
今のところMithrilのAPIを直接使用しているが、当然SXMLか何かでwrapしたAPIを出すべきで、どうすれば良いのかは考え中。

展望

重要なポイントは、yuniで書いたアプリケーションを動的なホスティングなしでWebアプリにできる点な気がしている。つまり、ビルド済のyuniのランタイムと今回作成したWeb版biwasyuniを適当にGitHub pagesかどこかに入れておけば、(原理的には、)GitHubリポジトリ上に配置したyuniアプリを直接起動できることになる。
...別に今クラウドに払っている金額を考えれば(営業のために)動的にアプリをビルドして配信するようなサーバを作ることもコスト的には大したことはないが、組織内での使用などインターネット上にあっても仕方ないケースもある。
当然、見ず知らずのWebアプリを自分のドメインで動かすというのは本質的にXSS脆弱性なので、何らかの対策を入れる必要は有る。例えば、Scheme側からのJavaScript evalを禁止するとか、DOMは信頼したライブラリにしか見せないといった対策は必要だろう。

"アプリケーションのビルド"をSDKとして抽象化できるのか問題

追記: Racket 7は--embed-dllsで完全にstandaloneな.exeを生成できるようになった https://blog.racket-lang.org/2018/07/racket-v7-0.html
実は意外に多くのScheme処理系が単体アプリケーションのビルドを機能としてサポートしている。
Gaucheは最新リリースの0.9.6でbuild-standaloneユーティリティ( http://practical-scheme.net/gauche/man/gauche-refe/Building-standalone-executables.html#Building-standalone-executables )を提供し、スクリプトとライブラリを文字列の形で取り込んだexecutableを作ることができる。ツールは(まだ実験的な)precomp( https://github.com/shirok/Gauche/blob/master/doc/HOWTO-precompile.txt )を活用しない。build-standaloneはSchemeソースコードをC文字列に変換し、簡単なmainスタブとともにコンパイルすることでアプリケーションをビルドする。コンパイラgauche-config --ccのものがそのまま使用される。
ChezSchemeはmake-boot-file手続きでbootファイルを作り、それとバンドルされているstatic library形式の処理系を組み合わせて単体アプリに仕立てることができる。解説( http://cisco.github.io/ChezScheme/csug9.4/use.html#./use:h8 )は商用版の名残りでpetit chez schemeを使っているが、同じことはコンパイラ入りの完全版でも可能なはず。これと言ったフロントエンドの類いは用意されていない、つまり、ユーザは自分でCコードを書く必要がある
Guileは元々Extension languageとして作られたので当然ライブラリ(libguile)を提供し、ドキュメントも存在する場合はC APIScheme APIを常に併記している。が、逆、つまりSchemeで書かれたアプリケーションを配布するためにアーカイブする仕組みは備えていない。一応バイトコードコンパイラを直接呼ぶことはできる( https://www.gnu.org/software/guile/manual/html_node/Compilation.html )が、バイトコードを直接ロードする良い方法は無い。...じゃぁ相当量のコードがGuileで実装されているLilypondのようなアプリケーションはどうしてんのかというと、システムにインストールされているlibguileに直接依存する形を取っている。
RacketはたぶんScheme処理系では最も考察が進んでいて、パッケージングツールであるracoがライブラリを含め単体実行ファイルを生成するraco distribute( https://docs.racket-lang.org/raco/exe-dist.html )を持っている。実際の実行ファイルを生成するraco exeコマンド( https://docs.racket-lang.org/raco/exe.html )はテンプレートになる.exeやmacOSアプリケーションのアイコンの差し替えまでサポートしている(UNIXでは単にテンプレートとなるexecutableのコピーになる)。更に、静的ライブラリも同時に提供し、アプリケーションへの組込みも可能になっている( https://docs.racket-lang.org/inside/embedding.html )。

最小公約数としての静的ライブラリ提供

というわけで、Gauche、ChezScheme、Racketは静的ライブラリとして処理系を配布しており、バイトコード化した(または、C文字列化した)プログラムを起動するための簡便なインターフェースを持っていると言える。なので、yuniのSDKとして何か提供するとするならば、

  1. 各処理系向けの簡単なmain()関数
  2. 各処理系でのバイトコードコンパイラやライブラリパッケージャを呼ぶCMakeモジュール

端的に言うとChezSchemeの方式が基準になる。GacuheはCコードの生成、Racketはカスタマイズされた.exeの生成までサービスしているが、アプリケーションに追加の静的リンクライブラリをリンクしたいケース等を考えると、C側のmain()の準備とアプリケーションのリンクは自前でやってしまった方が融通が効くような気がしている。
... 処理系を静的リンクライブラリで配るのか動的リンクライブラリで配るのかはちょっと悩ましい問題で、どちらもpros/consが有る。WindowsmacOSのように、実行ファイルがPIC(位置独立コード)であることが前提のプラットフォームであれば、静的リンクライブラリで配ってしまって利用者に委ねる方式も取れなくはないが。。特にWindowsの場合、処理系をビルドするのに使用したC言語ランタイムがMinGWなのかMSなのかという問題まであるため、動的リンクライブラリのメリットもそれなりに有ると言える。
yuniが他と比べて特殊なのは、yuniを使うアプリは殆どがネイティブコードを含んでいるという事実で、どうしてもアプリケーションのビルドには追加のビルドツールの存在を想定することになる。このため、ネイティブコード側の処理をScheme処理系に任せたいモチベーションがあまり無い。
Guileのような処理系や、Gambit、ChickenのようなSchemeコンパイラでも同じような方針で単体executableの生成は実装できる。ただGuileのような方式、つまりシステムに処理系をインストールしなければならない方式では、アプリケーションを単体で配布することができない - が、そもそもGuileはWindowsでよく動作しないのであまり大きな問題では無いはず。

hashtablesとserializeライブラリ

久々にyuniに新しいライブラリを足すことにした。どちらも他所でライブラリにしていたけどyuni本体に有った方が何かと便利なんじゃないかということで移動。

hashtables

yuniは基本的に"R7RS smallにあるものはR7RS、そうでなくてR6RSにあるものはR6RS"という方針で選択している。というわけでhashtablesはR6RSベース。
また、R6RSのものを更にサブセットしている:

  • ハッシュ関数は無し。
  • インスペクションは無し。つまり、ハッシュ関数やequality手続きを既存のハッシュテーブルから取り出すことはできない。
  • キーの型限定hashtablesを提供する。integer / string / symbol の3種。

この限定はScheme処理系を既存のインタプリタ言語で実装するケースを想定していて、integerやstringに関しては、それぞれのホスト言語ネイティブのハッシュテーブルを活用できる可能性があることから。
hashtableかhash-tableかは微妙なポイントで、SRFI-125ではhash-tableを推しているが( https://srfi.schemers.org/srfi-125/srfi-125.html )、yuniではプリミティブオブジェクトは1単語原則でhashtableにした。(e.g. byte-vectorでなくbytevector)
実はオブジェクトのハッシュは言語上に言語を載せる場合には良く実装できない可能性があり、可能な限り型付きのハッシュテーブルを想定した方が移植性は高くなる。ハッシュ関数は普通に使えても良いかもしれないが、移植性の問題と、eq/eqvに関しては再現性の問題がある(eq? の成立するオブジェクトに異なるハッシュ値を振る可能性がある)ため実はあんまり役に立たない。R6RSのRationaleでは:

The make-eq-hashtable and make-eqv-hashtable constructors are designed to hide their hash function. This allows implementations to use the machine address of an object as its hash value, rehashing parts of the table as necessary if a garbage collector moves objects to different addresses.

serialize

serializeライブラリは要するにFASLだが、"バイナリ形式のwrite/read"という位置付けとなる。API名は良いものが全く浮ばない。。いっそのこと fasl-read とか fasl-write にしてしまった方が良いかもしれない。。
オブジェクトのシリアライズ機能は処理系によってマチマチで、GambitやChezScheme等のようにちゃんとFASLとして使用できるものもあれば、そもそもシリアライズ機能自体を提供していない処理系もある。
最低でも、全てのread-write invariantなオブジェクトは正常にシリアライズできるという要求で良いと考えているが、問題は"writeできないがシリアライズはできた方が良いもの"をどうするかという問題だろう。
chibi-scheme、chicken、Gauche、Guile、Sagittarius などにはシリアライズの直接的なサポートはない。これらでは自前のWriter/Readerで対応することになる。

... 今見てみるとyuniのターゲット処理系でネイティブのFASLを持つ処理系って3つしか無いのか。。
これらのネイティブサポートは、一般にread/write invariantであるものの他に、ハッシュテーブルやrecordの入出力も可能となっている。ただし、yuniではハッシュテーブルの入出力は上記のinteger/symbol/stringの3種に限定しようとしている。固有のハッシュ関数やequality手続きを持ったハッシュテーブルを入出力するためには、これらの手続きもシリアライズする必要があるが、クロージャシリアライズは処理系のサポートが無いと困難なため。
ネィティブサポートの無い処理系の扱いは悩みどころだが、とりあえずScheme側の実装でどの程度のパフォーマンスになるかを見たいところ。おそらく殆どのケースで、テキスト形式のread/writeには勝てないのではないだろうか。
...じゃぁシリアライズなんて要らないじゃんということになりそうだが、

  1. read/writeだとyuniのプログラムでは頻出するbytevectorのI/Oに使えない
  2. 大きなデータセットを繰り返し処理するようなケースで必要になる - 今のユースケースだとFASL形式で100MiB 〜 500MiB程度のデータを処理しているのでテキスト形式に落してしまうとI/Oオーバヘッドが大きくなってしまう。(Twitterに挙げたgccのログデータセットは、インデックス処理後でも500MiBを超えるくらい https://twitter.com/okuoku/status/1018833935797645312 )
  3. スレッド非サポートの処理系では、並列処理時に結果を受け取る際に使用する。

というあたりで、個人的にはyuniのような位置付けのライブラリにはどうしても必要なんじゃないかと考えている。