Ubuntu 18.04 LTSで動くUSBドングル購入

自宅で使っているデスクトップパソコンは、USB経由でWiMaxのルータに接続してインターネット接続をしています。 ルータの電源をパソコンからではなくて、コンセントから供給するために、パソコンとルータの通信は無線化することにしました。

少し検索して、I-O DATAのWN-G150UMKを購入しました。Amazonでたったの685円。 黒は685円なのに、なぜか白は1621円という謎。

早速パソコンに挿して見ると、画面右上にWiFiのマークが現れましたので、クリックしてSSIDの選択、 パスワードの入力で接続できました。ドライバを手動でインストールする必要もありませんでした。

iwconfigで見てみると、Bit Rate=1 Mb/sと常に表示されています。でも実際はしっかり速度は出ているようですので、 表示にはバグがありそうです。

**2019/12/13 追記:**次はBluetoothはどうかと思って少し調べてみたところ、 USB 4.0 Bluetoothドングル CSR4.0+ EDRというモノだとUbuntu 18.04で動作しているというコメントがありました。

**2021/2/6 追記:**光回線の導入に合わせて、5GHzにも対応したTP-Linkのドングルを購入しました。こちらはドライバの手動インストールが必要でした。

GCEのf1-microのUnix Bench

タイトルの通り、GCE(Google Compute Engine)のf1-micro(無料枠で使えるもの)のベンチマークを取りましたので、 備忘録として貼っておきます。

   #    #  #    #  #  #    #          #####   ######  #    #   ####   #    #
   #    #  ##   #  #   #  #           #    #  #       ##   #  #    #  #    #
   #    #  # #  #  #    ##            #####   #####   # #  #  #       ######
   #    #  #  # #  #    ##            #    #  #       #  # #  #       #    #
   #    #  #   ##  #   #  #           #    #  #       #   ##  #    #  #    #
    ####   #    #  #  #    #          #####   ######  #    #   ####   #    #

   Version 5.1.3                      Based on the Byte Magazine Unix Benchmark

   Multi-CPU version                  Version 5 revisions by Ian Smith,
                                      Sunnyvale, CA, USA
   January 13, 2011                   johantheghost at yahoo period com

------------------------------------------------------------------------------
   Use directories for:
      * File I/O tests (named fs***) = /home/xxx/byte-unixbench/UnixBench/tmp
      * Results                      = /home/xxx/byte-unixbench/UnixBench/results
------------------------------------------------------------------------------


1 x Dhrystone 2 using register variables  1 2 3 4 5 6 7 8 9 10

1 x Double-Precision Whetstone  1 2 3 4 5 6 7 8 9 10

1 x Execl Throughput  1 2 3

1 x File Copy 1024 bufsize 2000 maxblocks  1 2 3

1 x File Copy 256 bufsize 500 maxblocks  1 2 3

1 x File Copy 4096 bufsize 8000 maxblocks  1 2 3

1 x Pipe Throughput  1 2 3 4 5 6 7 8 9 10

1 x Pipe-based Context Switching  1 2 3 4 5 6 7 8 9 10

1 x Process Creation  1 2 3

1 x System Call Overhead  1 2 3 4 5 6 7 8 9 10

1 x Shell Scripts (1 concurrent)  1 2 3

1 x Shell Scripts (8 concurrent)  1 2 3

========================================================================
   BYTE UNIX Benchmarks (Version 5.1.3)

   System: db: GNU/Linux
   OS: GNU/Linux -- 4.15.0-1029-gcp -- #31-Ubuntu SMP Thu Mar 21 09:40:28 UTC 2019
   Machine: x86_64 (x86_64)
   Language: en_US.utf8 (charmap="UTF-8", collate="UTF-8")
   CPU 0: Intel(R) Xeon(R) CPU @ 2.20GHz (4400.0 bogomips)
          Hyper-Threading, x86-64, MMX, Physical Address Ext, SYSENTER/SYSEXIT, SYSCALL/SYSRET
   00:12:07 up 3 days, 11:23,  1 user,  load average: 0.13, 0.03, 0.01; runlevel 2019-04-30

------------------------------------------------------------------------
Benchmark Run: Sat May 04 2019 00:12:07 - 00:43:23
1 CPU in system; running 1 parallel copy of tests

Dhrystone 2 using register variables       19549752.8 lps   (10.1 s, 7 samples)
Double-Precision Whetstone                      965.7 MWIPS (33.6 s, 7 samples)
Execl Throughput                                771.9 lps   (29.9 s, 2 samples)
File Copy 1024 bufsize 2000 maxblocks        112448.3 KBps  (30.1 s, 2 samples)
File Copy 256 bufsize 500 maxblocks           37081.8 KBps  (30.1 s, 2 samples)
File Copy 4096 bufsize 8000 maxblocks        246207.9 KBps  (30.1 s, 2 samples)
Pipe Throughput                              281428.4 lps   (10.2 s, 7 samples)
Pipe-based Context Switching                  66004.8 lps   (10.1 s, 7 samples)
Process Creation                               2366.5 lps   (30.2 s, 2 samples)
Shell Scripts (1 concurrent)                   1486.9 lpm   (60.2 s, 2 samples)
Shell Scripts (8 concurrent)                    205.7 lpm   (60.2 s, 2 samples)
System Call Overhead                         202476.7 lps   (10.1 s, 7 samples)

System Benchmarks Index Values               BASELINE       RESULT    INDEX
Dhrystone 2 using register variables         116700.0   19549752.8   1675.2
Double-Precision Whetstone                       55.0        965.7    175.6
Execl Throughput                                 43.0        771.9    179.5
File Copy 1024 bufsize 2000 maxblocks          3960.0     112448.3    284.0
File Copy 256 bufsize 500 maxblocks            1655.0      37081.8    224.1
File Copy 4096 bufsize 8000 maxblocks          5800.0     246207.9    424.5
Pipe Throughput                               12440.0     281428.4    226.2
Pipe-based Context Switching                   4000.0      66004.8    165.0
Process Creation                                126.0       2366.5    187.8
Shell Scripts (1 concurrent)                     42.4       1486.9    350.7
Shell Scripts (8 concurrent)                      6.0        205.7    342.8
System Call Overhead                          15000.0     202476.7    135.0
                                                                   ========
System Benchmarks Index Score                                         271.8

Amazon Lightsailのベンチマーク(UnixBench)とか貼っておきますね。と比べてみると、かなりパフォーマンスは低いようです。 最後のSystem Benchmarks Index Scoreでは、Lightsailの1971.6に対して、271.8と13%程度の性能です。

永田町から中野新橋まで散歩

今日は永田町から中野新橋まで、おおむね下のルートで歩きました。

“ルート”

永田町についた時は、少し雨が降っていましたので、傘が不要になるまで待ちました。 日テレ通りを北上し、麹町を通り越して市ヶ谷に到着です。

“市ヶ谷の外堀”

防衛省の北側を回りこむと、大日本印刷のビルが林立しています。昔の五反田のソニー村のようです。 西に進んで牛込柳町を通り、大久保通りを西に歩きます。

国立国際医療研究センター病院を過ぎて右に曲がって、戸山公園の箱根山(44.6m。標高と思われる。 実際に登るのは10m位?)に登頂しました。管理事務所で自己申告をすれば、登頂証明書をもらえるそうです。

“箱根山”

頂上からの眺めが良いわけでもないので、そちらは撮影しませんでした。

西に進んで、新宿コズミックセンターという謎の施設(どうやらスポーツ施設のようです)を周り、 新宿コズミック通りという微妙なネーミングの通りを西に向かいます。 この道沿いに早稲田大学の理工学部があるようです。

新大久保の北で山手線をくぐり、東中野まで歩きました。東中野からは、山手通りを南下し、 中野坂上まで。休日のせいか、山手通りはそれほど渋滞もしておらず、道も綺麗に感じました。 ちょうどよい起伏のおかげで、見通しがよく感じます。

本当は東中野近辺で食事をしたかったのですが、めぼしいところが見つからなかったので、 結局中野坂上のマックに入りました。最近はビッグマックのセットが690円するのですね。 アメリカの物価に合わせているのでしょうか、コストに対する満足度が低かったですね…

それから南西に歩いて、本二東郷やすらぎ公園を抜けます。そして神田川まで出ます。

“神田川”

神田川と都庁。大分晴れてきました。

もう少し西に歩いて、丸ノ内線中野新橋から帰途につきました。Googleマップで調べてみると、 大体10km程度歩いたようです。

Stackで作成したプロジェクトディレクトリを移動したらbuildが通らなくなった

タイトルの通り、Haskell Stackで作成しているプロジェクトを、テスト用のディレクトリから正式なプロジェクト管理用ディレクトリに移動したところ、stack buildが失敗するようになってしまいました。

エラーメッセージは正確に記録しなかったのですが、/usr/bin/ld.goldがエラーを出していて、 プロジェクトが使用しているHaskellライブラリの.soファイルが見つからない、 といったようなメッセージを出力していました。

stack buildが通らなくなった時のメモを見てみましたが、libgmpといった、システム全体にインストールされているライブラリではないので、症状は異なります。 同ページに記載されていた、stack setup –reinstallを試してみましたが、相変わらずstack buildのエラーは同じでした。

結果としては、プロジェクトディレクトリにある、.stack-workディレクトリをrm -rfで削除して、再度stack buildしたら成功しました。

Kensingtonのトラックボールを購入

机には透明のビニール製マットをひいて、その上で光学式マウスを使用していましたが、 最近カーソルが思い通りに動かない場面が多く感じてストレスになっていました。 それで思い立って、トラックボールに手を出してみました。

“Trackball”

Kensington Orbit Trackball with Scroll Ringです。初日は、なんとなく右方向に回転する時にイマイチなフィーリングでしたが、少ししたら引っかかりは無くなりました。これでマウスのストレスから解放されました。机の上があまり広くない場合もお勧めできると思います。 ただ、中ボタンが無いため、沢山ボタンが欲しいという場合は避けたほうが良いでしょう。

最近少し忙しくて、OFDM関連の作業を続ける気力が無くなっています。 ビタビ符号の復号は実装したのですが、まだ記事にできていません。残るReed-Solomon符号のところは、 最初はOSSのものを使用して動作させようかと思っています。

それと別件でWebアプリを一個完成させたいとも思っているので、なおさら集中できてません。

ストレス発散のための買い物かも知れません。

ワンセグ生データをOFDM復調(4)

前回、パイロット信号が正しく取り出せないのはキャリア周波数オフセット(Carrier Frequency Offset, CFO)が原因ではないかと書きました。それ以後、 OFDM Baseband Receiver Design for Wireless Communicationsを参考に、 CFO, SCO(Sampling Clock Offset)を除去するためのPLLを実装してみたりもしましたが、どうにも思うとおりにはなりませんでした。

考え直してさらに調査した結果、DFTのデータを取り出すウィンドウが間違っていたということが判明しました。

これまでは、相関値が最大になる場所をDFTウィンドウの先頭としていましたが、 それだとCyclie Prefix(ガードインターバル)の先頭からOFDMシンボルを切り出してしまうことになります。 この領域は、信号に遅延波があるときに、他のシンボルからの干渉を避けるためのものなので、 本来DFTに使用するのは望ましくありません。

遅延波が全く無かったとしても、上記の文献の式(5.11)の通り、周波数領域の信号Zkは、送信信号Xk、チャンネル応答Hkに対して次のようになります。

“Symbol Timing Offset”

ここに、TdはDFTウィンドウがCyclic Prefixに入り込んでいる時間です。

これまでのTs=1, Td=128, N=1024で計算してみると、e^{-j2pi128k/1024}= e^{-jpi*k/4}となります。つまり、隣り合ったサブキャリアの間で-pi/4だけ位相が本来の信号からずれていくことになります。

これまでの結果を振り返ってみると、サブキャリア12本ごとにpi位相がずれている様子でした。 計算すると、-pi/4*12=-3pi=pi (modulo 2pi)ですので、ちょうどこの結果と一致します。

それで、CPの開始からDFTウィンドウを設定するのではなく、CPの終わりの少し前からDFTウィンドウを設定することで、ほぼ所望の結果が得られるようになりました。

“Symbols Before Channel Estimation”

上はチャンネル補正をする前の、生のDFT後のデータです。 これだけバラけていると、QPSKであることも疑わしい感じですね。

“SP”

SP自体は上のように、まだキャリアごとに、わずかに回転しています。このSPによって、残りのチャンネルは線形補間で推定します。推定結果でデータを補正すると、次になりました。

“Symbols After Channel Estimation”

このように、CFO, SCOのためのPLLは一切使用せずに、相関値を算出したときのガンマの値による回転と、整数倍のチャンネル周波数補正、SPによるチャンネル補正のみで、QPSKの信号がほぼ復元できました。

流石に、この程度のコンステレーションでは64QAMは無理ですが、4象限しか区別しないQPSK程度であれば、これでも十分に思われます。また、TMCC, ACに相当するI軸上の点も見つけられます。

ここまで来れば、TMCCのデコードをはじめ、ワンセグのデコード処理も規格書に則って進めれば完成まで持っていけそうな気がしてきました。

ワンセグ生データをOFDM復調(3)

前回までで、ワンセグの生データからのOFDMシンボル切り出し、パイロット信号を見つけるところまで実装しました。ところが、パイロット信号からチャンネル補正ができそうにない、というところで止まってしまいました。もう少し調査したところ、原因が分かってきました。 自分の理解の範囲でのまとめです。

OFDM Baseband Receiver Design for Wireless CommunicationsのChanter 5. Synchronizationの式(5.6)は次のようになっています。

“Z”

これは、キャリア周波数のオフセットが、FFTした結果に及ぼす影響を記述した式です。 左辺Z_{i, k}は、(時系列に並んだ)i番目のシンボルの、k番目のサブキャリアの(周波数領域の)値を示しています。また、ε_I, ε_fがそれぞれキャリア周波数オフセットの整数部、小数部です。 Hはそれぞれのサブキャリアに対応するチャンネルの周波数応答です。

ごちゃごちゃしていますが、とにかく、ε_I, ε_fが0でなければ、Z = X Hとは記述できず、他のキャリア周波数の影響を受けてしまうことを示しています。

右辺の第一項を見てみると、大きく4つの掛け算になっていますが、最後の2つのe^{…}の部分は純粋な回転です。 これまでの実装で、小数部のキャリア周波数補正はできていると思われます。 そのため、ε_fはほとんど0と考えます。

したがって、振幅だけを見るならば、受信したk番目のサブキャリアのFFT結果は、送信側のk-ε_I番目のサブキャリアの値に、チャンネル応答を掛けたものになっています(sinの項はε_f->0で1になるため)。 ですから、パイロット信号も含めてXの振幅は既知なので、振幅について積算することで、 ε_Iを求めることができたのでした。

問題は3番目のeの項による回転です。 この式を見るとε_Iが0でなければ、時間と共に盛大に回転します。 (2019/2/20追記:これ自体は正しいですが、kには依存していませんので、 Scattered Pilotがキャリアによって回転量が異なることの説明にはなりません。 それより(5.6)右辺第2項が寄与しているのかも知れません)

これの逆数を求めて回転を補正することもできるかも知れませんが、 上記の式では右辺には第2項以降も存在します。 そのため、基本的にはε_I, ε_fを0に収束させるような制御をしたほうが良さそうです。

上記の本の5.2.3節にも、周波数領域の推定アルゴリズムには限度があるので、同期エラーはその限界内に来ていることを確かめないといけない、といった記述があります。そのためには、時間領域で補正することができる、と書かれています。

時間領域での補正方法は下記の図のようになります(Figure 5.13より)。

“CFO compensation PLL”

求められたε_I, ε_fをloop filterを通してNCOに与え、その周波数でサンプリングしたデータを回転させる、というものです。 これまでの実装ではε_fのみしか考慮していませんでしたが、ε_Iも考慮する必要がある、ということです。

以上を参考にして、PLLでキャリア周波数オフセットを0に制御できるか試してみたいと思います。

ワンセグ生データをOFDM復調(2)

前回までで、ワンセグの生データからのOFDMシンボル切り出しあたりまで実装しました。今回はそれ以降の処理についてです。

前提知識

ワンセグも含めて、地上波デジタル放送は、ARIBにて規格書が配布されています。

標準規格(放送分野)一覧表のSTD-B31「地上デジタルテレビジョン放送の伝送方式」から入手可能です。私は会員でもないので、英語版を入手しています。

FFT以降の処理

前回の部分も少し修正して[11]については、次のようにしました。

“FFT”

重要なのはfftshiftを実施していることです。データ取得時のサンプリング周波数は、放送帯域の中心に設定しているので、(キャリア周波数オフセットが0であれば)fftshift後の配列中央の値が、放送帯域の中心キャリアになるはずです。

また、後でキャリア周波数の整数倍の補正をするために、周波数シフトをする必要があります。そのためのシフト量を保持するために、F_leftという変数を定義しています。シフト量が0であった場合に、FFT結果Fのうちキャリア番号0に対応する値を、初期値として設定しています。

“PRBSとTMCCの定義”

パイロット信号を検出するためにPRBSが必要になるので、その定義と、TMCC、ACという特別なパイロット信号のキャリア番号を定義しています。PRBSの最初の値がキャリア0に、次の値がキャリア1に、…というように、432本のキャリアにそれぞれ0, 1のいずれかが対応します。SP(Scattered Pilot)信号は、このキャリア周波数毎の0, 1の値がBPSKにより、IQ空間上のそれぞれ(+4/3, 0), (-4/3, 0)にマップされます。これを上記では配列Wiで定義しています。

“TMCCとAC推定”

上記は、TMCCとACの場所を推定するための関数です。G2では、引数mで指定された分だけ、キャリア周波数を移動した場所をTMCC, ACとみなして、絶対値の積算をしています。

Gs = [G2(i, Wi) for i in range(-20, 20)]

にて、キャリア周波数を-20から20まで整数倍シフトして、G2の積算値が最大になる場所を求めます。最大値が得られるインデックスでF_leftを調整しています。これでキャリア番号0から431までの場所が求まったことになるはずです。上記のプロット結果は省略します。

“SP推定”

次はSPの推定です。SP信号は12キャリアに1本あります。OFDMシンボル毎に場所が3キャリアずつ移動します。つまり、OFDMシンボル毎に、SPの場所はi12, i12+3, i12+6, i12+9, i*12,…のように巡回します。上記find_spでは、現OFDMシンボルでSPが何処に含まれているかを求めています。SPは、それ以外のデータキャリアに比べて4/3倍の絶対値を持っているので、上記のように積算した最大値を求めるという方法が使えます。

ここからがまだうまく行っていないと思われるのですが、上記では次のような出力が得られます(行が長くなりすぎるので、小数点以下は一部省略しています)。

[35.74547, 36.26827, 49.57394, 37.08745]
2
SPs:
6 1.0953 (-0.9832743533464458+0.482584084887267j) 1.3333
18 1.1773 (0.9411424714348882-0.7073685094875273j) 1.3333
30 1.2927 (0.9226357042956151-0.905485176200131j) -1.3333
42 1.3544 (-0.9451630023246448+0.9701050133998432j) -1.3333
54 1.3868 (-1.041607866189769+0.9155528236391836j) 1.3333
66 1.2944 (0.6906246945088386-1.0947704458085812j) 1.3333
78 1.0671 (0.6685409527954194-0.8316726977365281j) -1.3333
90 1.3859 (0.7967161344290873-1.1339926757265295j) 1.3333
102 1.1538 (-0.34105207413393135+1.102293053251151j) 1.3333
114 1.2401 (0.4762959068094629-1.144985106068488j) 1.3333
126 1.5607 (0.725557306133454-1.381834432391039j) -1.3333
138 1.2347 (-0.6547734652174335+1.0467712733713745j) -1.3333
150 1.3194 (-0.3329626554307+1.2767023965626096j) 1.3333
162 1.5988 (0.5091991640004263-1.5155690510396185j) 1.3333
174 1.3907 (-0.6562514301534865+1.2261640531390314j) 1.3333
186 1.6392 (-0.5092859972519473+1.5580715920605588j) -1.3333
198 1.5107 (0.26879055172568034-1.4866408415508643j) -1.3333
210 1.6382 (0.7524038499463859-1.4552230916819244j) 1.3333
222 1.3896 (0.44737449638796445-1.3156195604230028j) -1.3333
234 1.3833 (0.5356609612670982-1.2753992069341795j) 1.3333
246 1.559 (0.3497743406085074-1.5192501362727482j) -1.3333
258 1.1333 (-0.42997707286624176+1.048549775299747j) -1.3333
270 1.3702 (-0.2787479092644133+1.3415065163031206j) 1.3333
282 1.6204 (-0.5708941597565+1.5164865623719384j) -1.3333
294 1.4883 (0.6930350159378798-1.317100025661765j) -1.3333
306 1.5654 (-0.7261325631313592+1.3867477986830758j) -1.3333
318 1.5449 (0.4601928773417365-1.4747525850693373j) -1.3333
330 1.4912 (0.5746828035844906-1.3760673756943445j) 1.3333
342 1.4701 (0.825690594740095-1.2162618274644958j) -1.3333
354 1.4209 (0.24893414863314506-1.398884931842842j) 1.3333
366 1.3512 (-0.6292706725628592+1.1957269229318115j) 1.3333
378 1.4125 (-0.5413499265712505+1.3046784927444803j) -1.3333
390 1.4662 (0.7501030028129436-1.2598034492601253j) -1.3333
402 1.1753 (-0.7940964094918974+0.8664208985515854j) -1.3333
414 1.3589 (-0.7997872953740967+1.098570575241878j) 1.3333
426 1.0329 (-0.3727620060886928+0.96334271774669j) -1.3333

最初の行と次の行は、SPの場所と絶対値の積算値を表示しています。49は残りの3つの値36付近に比べると、ほぼ4/3倍となっているので、結果は正しそうに思われます。

ワンセグ生データをOFDM復調(1)

前回、GNU RadioとADALM PLUTOを使って、 ワンセグの生データを取得してファイルに保存しました。

いきなり余談(Jupyterを使うまで)

現在、Pythonにて上記のデータをデコードできるかを実験しています。 最初はCでプログラムを作成し始めました。でも、実装していると、 途中途中までで、思い通りに処理ができているかを確認したくなります。 そのために、グラフを作成する必要が出てきました。CImgなど、 C++で比較的に簡単に使えるライブラリも試しました。これは画像生成は簡単ですが、 グラフ生成は面倒に感じました。

それで、結局Jupyter Notebookを使ったほうが簡単だということで、Pythonに移行しました。アルゴリズムが固まったら、データを一気に処理するためにC,C++に戻る予定ですが。

ここから本題

キャプチャした生データをソフトウェアで処理して、MPEG TSを得るところまで持っていきたいと考えています。そのためには、まずなによりも、OFDMの復調処理が必要です。 ここがクリアできれば、残りはそれほど難しくないと予想しています。

以下、Notebookの画像を貼り付けていきます。

“相関値算出まで”

上記In [1]は、各種ライブラリを使用することを宣言しています。[2]でcapture.rawという、GNU Radioで保存したファイルを開いて、dataという変数に読み込みます。 ファイルの一番先頭は少し飛ばしています。

[3]はガードインターバルとシンボル長の定義です。 ADALM PLUTOでは、64/63MHzでサンプリングしています。1シンボルは1008[us]なので、 計算してみると、ちょうど1024というキリの良い個数になります(そうなるようにサンプリング周波数を設定している)。

(少なくとも日本の)ワンセグのガードインターバルはシンボル長の1/8なので、128になります。

[4]は、シンボルの境界を検出するための相関値を求める関数correlateの定義です。 この方法については、 “An Open and Free ISDB-T Full_Seg Receiver Implemented in GNU Radio” Federico Larroca et al.で最初に知りましたが、 “ML Estimation of Time and Frequency Offset in OFDM Systems” Van De Beek et al.がオリジナルのようです。

最初の論文の(1)式の計算になります。ざっくり説明すると、シンボル長だけ離れた、ガードインターバル長サンプルの相関(積和, 上記gamma)を計算しています。また、RHOは本来であればSNR/(1+SNR)で定義されるのですが、今回は定数としています。十分SNRが大きければ1に近づく値なので、 ひとまずこのように置いても問題なさそうです。

“極大計算”

[5]では、実際に先頭をずらしながら相関値を計算していき、配列corに入れます。 correlateが返すのはタプルなので、[6]で分解してそれぞれの配列を得ます。 配列corrsの値が極大になっているところが、OFDMシンボルの区切りです。

[7]はグラフを見やすくするためのサイズ調整です(横幅を伸ばしている)。

[8]で相関値とガンマの値を時系列のグラフにしています。結果が次です。

“correlation”

上記Van De Beekの論文のFigure 4と同じような結果です。横軸はサンプルの番号です。赤い線が左の縦軸に対応しており、相関値を表します。青い線が右の縦軸に対応しており、ガンマの偏角を表します。

HaskellでGoogle OAuth2の認証等の実験

個人的なプロジェクトとして、ログイン機能を実装したWebアプリを作成したいと考えています。 独自のユーザ認証にするという方法もありますが、今回はGoogleのOAuth2を使う方法を検討しました。

HaskellのWebフレームワークであるSpockを使用しました。 また、OAuth2はwreqを使って、自分でHTTPリクエストを発行することで実装しました。

また、Webアプリ自体はポート8888でSSLを使用せずに待ち受けるようになっています。 SSL化をするためにnginxをインストールして、/etc/nginx/conf.d/test.confとして以下を作成します。

server {
   listen 443;
   server_name test.com;
   
   ssl on;
   ssl_certificate /etc/letsencrypt/live/test.com/fullchain.pem;
   ssl_sertificate_key /etc/letsencrypt/live/test.com/privkey.pem;

   ssl_session_timeout 15m;

   ssl_protocols SSLv2 SSLv3 TLSv1;
   ssl_ciphers HIGH:!aNULL:!MD5;
   ssl_prefer_server_ciphers on;

   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-Host $host;
   proxy_set_header X-Forwarded-Server $host;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

   location / {
     proxy_pass http://localhost:8888/;
     proxy_redirect default;
   }
}

これでnginxをSSL対応リバースプロキシとして動作させ、同一マシン上で動くHaskell WebアプリにHTTPリクエストを転送することができます。

プログラムはモジュールに分割せず、全部を1ファイルにまとめて書いてしまいましたが、次のようになりました。

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables,FlexibleContexts #-}
module Main where

import Web.Spock (SpockM, get, root, param, getState, runQuery, text, redirect, (<//>), spock, runSpock, var)
import Web.Spock.Config
import Web.Spock.SessionActions
import Database.PostgreSQL.Simple
import Data.Pool
import Database.PostgreSQL.Simple.FromRow
import qualified Network.Wreq as W
import Network.Wreq (FormParam(..))
import Control.Lens
import qualified Control.Exception as E
import qualified Network.HTTP.Client as H
import Data.Aeson.Lens (_String, _Integer, key)
import Data.Aeson.Types (Value)
import Data.Map.Strict (Map, (!?), insert, empty, (!))
import Control.Monad.Trans
import Data.IORef
import Data.Monoid
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Lazy (ByteString)
import qualified Data.Text as T
import qualified System.Random as R

type Sess = Map T.Text T.Text
data AppState = DummyAppState (IORef Int)
type Email = String
type ErrorMsg = String
type Resp = W.Response (Map String Value)

redirect_uri = "https://(GoogleからリダイレクトされるURL)/google_oauth_callback" :: T.Text
client_id = "(Googleから発行されるClient ID)" :: T.Text
client_secret = "(Googleから発行されるClient Secret)" :: T.Text

auth_uri = "https://accounts.google.com/o/oauth2/v2/auth" :: T.Text
exchange_uri = "https://www.googleapis.com/oauth2/v4/token" :: String
api_uri = "https://www.googleapis.com/oauth2/v2/userinfo" :: String
scope = "https://www.googleapis.com/auth/userinfo.email email" :: T.Text

db_name = "(データベース名)"
db_user = "(データベースユーザ名)"
db_pwd = "(データベースパスワード)"

main :: IO ()
main = 
    do ref <- newIORef 0
       pool <- createPool (connect defaultConnectInfo
                                     { connectDatabase = db_name,
                                       connectUser = db_user,
                                       connectPassword = db_pwd })
                             close 1 10 10
       spockCfg <- defaultSpockCfg empty (PCPool pool) (DummyAppState ref)
       runSpock 8888 (spock spockCfg app)

-- Here "id_token" contains JWT, which includes email address (See https://developers.google.com/identity/protocols/OpenIDConnect)
debugExchangeResult :: W.Response ByteString -> IO ()
debugExchangeResult r = do
    res <- W.asJSON r :: IO Resp
    putStrLn "======================"
    putStrLn $ show $ r ^. W.responseBody
    putStrLn $ dbgMsg res "access_token"
    putStrLn $ dbgMsg res "token_type"
    putStrLn $ dbgMsg res "expires_in"
    putStrLn $ dbgMsg res "refresh_token"
    putStrLn "======================"
    where dbgMsg res str = str ++ " : " ++ show ((res ^. W.responseBody) !? str)

getEmail :: String -> IO (Either ErrorMsg Email)
getEmail auth_code = do
    putStrLn ("authorization code : " ++ auth_code)
    r <- W.post exchange_uri
                      ["code"          := auth_code,
                       "client_id"     := client_id,
                       "client_secret" := client_secret,
                       "redirect_uri"  := redirect_uri,
                       "grant_type"    := ("authorization_code" :: T.Text)]
    let status_code = r ^. W.responseStatus . W.statusCode
    if status_code == 200 then
      do
        let access_token = show $ r ^. W.responseBody . key "access_token" . _String
        let opts = W.defaults & W.header "Authorization" .~ [B.pack $ "Bearer " ++ access_token]
        debugExchangeResult r
        r2 <- W.getWith opts api_uri
        putStrLn $ show (r2 ^? W.responseBody)
        let email = show (r2 ^. W.responseBody . key "email" . _String)
        case email of
          "" -> return $ Left ""
          _ -> return $ Right email
    else
      return $ Left "failed to exchange token"

handle_callback = do
 (Just code :: Maybe String) <- param "code"
 (Just state :: Maybe String) <- param "state"
 m <- readSession
 if (m ! "state" /= T.pack state) then
    text "state invalid"
 else
    (liftIO $ getEmail code `E.catch` handler) >>= handle_result
      where handle_result (Left msg) = text ("error " <> T.pack msg)
            handle_result (Right mail) = text ("Logged in as: " <> T.pack mail)
            handler e@(H.HttpExceptionRequest req cont) =
                         do liftIO $ putStrLn $ show cont
                            return $ Left $ show cont

handle_hello name = do
 (DummyAppState ref) <- getState
 [Only xs] <- runQuery $ \conn -> query_ conn "select 1+1;" :: IO [Only Int]
 liftIO $ putStrLn $ show xs
 num <- liftIO $ atomicModifyIORef' ref $ \i -> (i+1, i+1)
 m <- readSession
 writeSession $ insert "name" name m
 text ("Hello " <> name <> ", number " <> T.pack (show num))

handle_login = do
 g <- liftIO $ R.newStdGen
 let state = take 30 (R.randomRs ('a', 'z') g)
 liftIO $ putStrLn state
 sessionRegenerateId
 m <- readSession
 writeSession $ insert "state" (T.pack state) m
 redirect (T.concat [auth_uri, "?client_id=", client_id,
                               "&redirect_uri=", redirect_uri,
                               "&state=", T.pack state,
                               "&scope=", scope, "&access_type=online&response_type=code"])
 
app :: SpockM Connection Sess AppState ()
app =
    do get root $
            do m <- readSession
               let name = m !? "name"
               case name of
                   Nothing -> text "Hello World"
                   Just n -> text ("Hello World, " <> n)
       get ("hello" <//> var) handle_hello
       get "login" handle_login
       get "google_oauth_callback" handle_callback

上記コードでは、OAuth2を用いてログインしたユーザのメールアドレスを取得しています。アクセストークン取得時にJWT(JSON Web Token)がid_tokenに発行されるので、それをデコードして、中のデータから取り出しても良いはずですが、もう一度APIを呼び出すことで、その結果からアドレスを取り出しています。