Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Arch LinuxでTPM2.0デバイスを使って暗号化したボリュームのパスフレーズ入力を省略する

以前の記事で、ルートファイルシステムを暗号化する手順を紹介しました。

blog.lufia.org

ルートとなるボリュームを暗号化すると、再起動のたびにパスフレーズの入力を促されますが、何度も入力するのは面倒です。そこで、TPMバイスに鍵を保存してパスフレーズの入力を省略しました。パスフレーズの入力が必須な状態と比べると少しだけセキュアではなくなりますが、個人的には許容できる範囲かなという印象です。

若干丁寧に説明を書いたので長い記事になってしまいましたが、証明書や鍵の管理を(直接は)していないので、やること自体はそこまで難しくないと思います*1

セキュアブートを有効にする

後で説明していますが、TPMバイスの保護機構では「セキュアブートの設定が変更されたときに保存している鍵を読み取れなく」できます。そのため、この手順でセキュアブートを有効にしていますが、不要なら飛ばしても影響はないと思います。

Arch Linuxでは、セキュアブートを実現する方法は以下の3通りあります。

  • PreLoader+HashTool
  • shim+MokManager
  • 自身の鍵で署名する

丁寧にやるなら、署名するためのマシンを分けて自身の鍵で署名するといいのだろうけど、そこまでするのは私物マシンの管理としては過剰に思いますし、同じマシンで署名するならそれほど意味があるとは思えないので、いちばん簡単なPreLoaderを使う方法を選びました。PreLoaderの方法では、悪意のあるブートローダカーネルに変更したあとでハッシュを追加すれば動いてしまうので、物理的にアクセスできる状況からは保護できませんが、少なくともリモートから書き換えられたとか、誤って不正なコードを実行した、などの状況は防ぐことができるので十分かなと思います。

セキュアブート自体の説明は以下の記事が分かりやすいですが、MOKにはあまり言及されていません。

PreLoader+HashToolの動作

まずはPreLoaderやHashToolが何を行うのか説明した方が理解しやすいと思うので書きます。PreLoaderはloader.efiを実行するだけのプログラムです。PreLoaderにはMicrosoftの署名が施されているので、セキュアブートが有効な状態でも問題なく実行できます。HashToolはPreLoaderから実行されるプログラムで、選択したファイルのハッシュをMOK(Machine Owner Key)へ追加するために使います。

従って、PreLoaderでセキュアブートを有効にすると、最終的にUEFIファームウェアは以下の順番でカーネルまで到達します。

  1. 電源を入れる
  2. UEFIファームウェアがPreLoaderを実行する
  3. PreLoaderはsystemd-bootのブートローダ(名前はloader.efiで固定)を実行する
  4. カーネルを実行する

このとき、UEFIファームウェア

などを確認します。検証に失敗した場合は実行エラーとなって、PreLoaderはHashToolへ制御を移します。HashToolはMOKハッシュ値を登録することができるので、ブートローダカーネルのハッシュをMOKへ登録して再度実行すると、今度は検証に成功するのでブートに成功します。

必要なプログラムのインストール

署名された状態のPreLoaderとHashToolが必要なのでAURからインストールします。また、ブートエントリに登録するためefibootmgrも必要です。

$ sudo pacman -S efibootmgr

$ git clone https://aur.archlinux.org/preloader-signed.git
$ cd preloader-signed
$ makepkg -si

これでPreLoaderとHashToolは/usr/share/preloader-signed以下にインストールされますが、このままではブート時に参照されないので、これを/boot/EFI以下へ手動でのコピーが必要です。また、PreLoaderloader.efiという名前のファイルを実行するので、systemd-bootのブートローダloader.efiとして参照できるようにします。

$ ls /usr/share/preloader-signed
HashTool.efi  PreLoader.efi

$ sudo cp /usr/share/preloader-signed/*.efi /boot/EFI/systemd/
$ sudo cp /boot/EFI/systemd/systemd-bootx64.efi /boot/EFI/systemd/loader.efi

上のコマンドではsystemd-bootx64.efiをコピーしていますが、これはPreLoader経由ではなくファームウェアが直接systemd-bootを実行するブートエントリも残すためです。セキュアブートに失敗してもすぐ戻せるように、残しておく方が安全です。

用意ができたらPreLoaderを実行するためのブートエントリを追加します。--partオプションはパーティション番号を指定するものです。

$ sudo efibootmgr --disk /dev/nvme0n1 --part 1 --create --label PreLoader --loader /EFI/systemd/PreLoader.efi

ここまでで、ブートエントリは以下のような状態になっているはず。

$ efibootmgr -v
BootCurrent: 0001
Timeout: 0 seconds
BootOrder: 0002,0001,0012,0000
Boot0000* Windows Boot Manager  HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\Microsoft\Boot\bootmgfw.efi)WINDOWS.......
Boot0001* Linux Boot Manager    HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\systemd\systemd-bootx64.efi)
Boot0002* PreLoader HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\systemd\PreLoader.efi)

セキュアブートを有効にする

PCを再起動します。このときUEFIのメニューでセキュアブートを有効にして、上記の手順で追加したPreLoaderブートエントリで起動しましょう。そうすると、この時点ではまだloader.efivmlinuz-linuxのハッシュが検証できないので、ブートの途中で検証エラーが発生して*2、HashToolが起動します。HashToolの画面では矢印キーを使って/bootパーティションのファイルを選択できるので、loader.efivmlinuz-linuxの2つを探して、ハッシュをMOKへ登録します。終わったらExitでHashToolを抜けて大丈夫です。

これで、セキュアブートを有効にした状態でブートできるのではないかと思います。セキュアブートが有効になっているかどうかは、以下のコマンドで確認できます。

$ sudo dmesg | grep 'Secure boot'
[    0.010526] Secure boot enabled
[    6.092896] Bluetooth: hci0: Secure boot is enabled

$ bootctl status
System:
     Firmware: UEFI 2.70 (Phoenix Technologies Ltd. 4660.22136)
  Secure Boot: enabled (user)
 TPM2 Support: yes
 Boot into FW: supported

なんらかの理由で起動に失敗した場合は、おそらくUEFIのメニューにブートオーダーの設定もあると思うので、ここから以前のブートエントリを優先に戻して起動すればよいです。

カーネルやsystemd-bootをアップデートした場合

systemdパッケージを更新しても、それだけではブートローダ/boot以下にコピーされません。都度bootctlcpを実行してもいいですが面倒なので、/etc/pacman.d/hooks/systemd-boot.hookなどに書いておくことをおすすめします。

[Trigger]
Type = Package
Operation = Upgrade
Target = systemd

[Action]
Description = Upgrading systemd-boot...
When = PostTransaction
Exec = /bin/sh -c 'bootctl update && cp "$(bootctl -p)"/EFI/systemd/{systemd-bootx64,loader}.efi'

また、vmlinuz-linuxloader.efiを変更した場合、ハッシュ値が変わるのでMOKでの検証が失敗します。このときは上記と同様にHashToolが起動するので、新しいファイルのハッシュを登録し直せば起動できるようになります。

気になりごと

Arch Linuxでは、カーネルは頻繁に更新されるので、HashToolで追加したハッシュの上限はあるのか、あるなら古いハッシュを削除する方法はあるのか、が気になっています。HashTool.cを読むと、MOKの実態はUEFIのMokList変数らしいので、これを扱う方法があればよいのですが、この値はブートサービス固有の変数で通常起動した後は見れません。少くとも/sys/firmware/efi/efivars以下では参照できませんでした。自由に書き換えられると困るのでそうですね...

それから、mokutilコマンドでdbをみたとき、dbに含まれているMicrosoftの証明書は期限が2026年でした。Windows環境ならファームウェアアップデートなどで対応するのかもしれませんが、Linuxではどうすればいいんだろう。

busybox initをsystemd initに変更する

Arch Linuxmkinitcpioは、デフォルトではbusyboxフックを使いますが、systemd-cryptenrollを使うためにはsystemdフックが必要なので切り替えます。難しく聞こえますが、やることは/etc/mkinitcpio.confHOOKS変数を変更するのと、この変更によってカーネルオプションの書式が少し変わるので対応するだけです。

まずはmkinitcpio.confの差分です。

--- mkinitcpio.conf.orig 2022-01-29 16:55:28.098086104 +0900
+++ mkinitcpio.conf   2022-01-31 01:24:39.829166470 +0900
@@ -49,7 +49,7 @@
 #
 ##   NOTE: If you have /usr on a separate partition, you MUST include the
 #    usr, fsck and shutdown hooks.
-HOOKS=(base udev autodetect modconf keyboard keymap block encrypt filesystems fsck)
+HOOKS=(base systemd autodetect modconf keyboard block sd-encrypt filesystems fsck)
 
 # COMPRESSION
 # Use this to compress the initramfs image. By default, zstd compression
@@ -64,4 +64,4 @@
 
 # COMPRESSION_OPTIONS
 # Additional options for the compressor
-#COMPRESSION_OPTIONS=()
+COMPRESSION_OPTIONS=(-9)

autodetectフックは省略可能ですが、このフックはinitramfs-linux.imgから不要なモジュールを削減してくれるものです。イメージは小さいほうが読み込み速度などで有利なので、基本的には含めるほうがいいでしょう。ただし、必要なモジュールなのに誤って削減される場合があるらしく、削減前のイメージをinitramfs-linux-fallback.imgとして生成します。

また、systemdフックはbusyboxフックに比べてファイルサイズが大きくなる傾向があるのでCOMPRESSION_OPTIONSも変更していますが、/bootパーティションに余裕があるなら設定しなくても問題ありません。

設定を更新したら、新しいinitramfs-linux.imgを生成します。

$ sudo mkinitcpio -p linux

systemdフックへの変更に伴い、カーネルオプションの書式も変わります。

--- arch.conf.orig   2022-02-12 22:05:35.920657630 +0900
+++ arch.conf 2022-02-12 22:05:41.680559575 +0900
@@ -2,4 +2,4 @@
 linux /vmlinuz-linux
 initrd /intel-ucode.img
 initrd /initramfs-linux.img
-options cryptdevice=UUID=ef718fc5-8eb5-44e8-904b-34c3705d3390:cryptroot root=/dev/mapper/cryptroot rw
+options luks.name=ef718fc5-8eb5-44e8-904b-34c3705d3390=cryptroot root=/dev/mapper/cryptroot rw

上記のArchWikiを読む限り、rootオプションは無くても動きそうにみえますが、無くしてしまうとブート時に、

A start job is running for /dev/gpt-auto-root

タイムアウトしてしまってブートできませんでした。

TPM 2.0にLUKS2の鍵を入れる

TPMは、雑に表現すると「機密情報を安全に保存するためのストレージ」です。tssグループに参加していれば一般ユーザーでも利用できるので、例えばSSH秘密鍵を安全に保存するためにも利用できます。一般的なストレージとの大きな違いは、ハードウェア構成やブート順などに変化があれば、保存されている機密情報を無効化できるところかなと思います。

無効*3にする条件にはPCR(Platform Configuration Register)の番号を与えます。PCRは0から開始する連番になっていて、ハードウェアによって上限は異なります*4が、0から7くらいまでの用途は事前に決められています。たとえばPCR0には「ファームウェアのコードに変更があれば変わる」ハッシュ値が保存されていて、PCR4はブートローダに変化があれば変わるハッシュ値です。この値は、

PCR[n] = sha256(PCR[n] + data)

のように計算されるので、一度でも変更されると、たとえ設定を元に戻しても以前の値には戻りません。well-knownな各PCRの値は以下のマニュアルにある表がわかりやすいかなと思います。

それぞれのPCRは独立して計算されますが、ブート順を変更すると一緒にパーティションテーブルの状態が変わるなどはよくあるので、その場合はどちらか片方だけ指定しておけば十分です。

PCRの変化例

具体的にPCRの変化を実験してみました。

  1. セキュアブートを無効に変更
  2. Archインストーラからブート

この場合、手元のハードウェアではPCR4、PCR5、PCR8が変化しました。

--- tpm2_pcrread 2022-01-30 03:10:30.473355620 +0900
+++ tpm2_pcrread  2022-01-30 03:09:52.159120902 +0900
@@ -7,2 +7,2 @@
-    4 : 0x510D823ADDB47C0F3AE033F820F5B139A5140D180BB71508B83686DC1873BC08
-    5 : 0xCEE0B028F90CDB9446AB335E2E7DB173908E1668AFDBA7E6C089E3911DE8059F
+    4 : 0x2BE06D1448029581DC41BFBC72F3E5F086F1104C2B16CA5F3F64A8F11FDD199F
+    5 : 0xB9344EFE635B503DB6474937C18839770460B3A4365916D07D13B00FD3D63AD6
@@ -11 +11 @@
-    8 : 0xE0C4DFC3BBDFBD8B79583B9FC5EC83912A2C86977D5FCC110C51C29D6EC846CF
+    8 : 0x3723C256E87505FC1174CCA1F71BC8145D8703D46AE88C88537DF9AF72E2AB0B

次に

  1. セキュアブートを有効に戻す
  2. カーネルを更新

したときの様子です。PCRの計算には現在のハッシュ値も含まれているので、元の値には戻りません。

--- tpm2_pcrread 2022-01-30 03:09:52.159120902 +0900
+++ tpm2_pcrread  2022-01-30 03:32:01.209482235 +0900
@@ -7 +7 @@
-    4 : 0x2BE06D1448029581DC41BFBC72F3E5F086F1104C2B16CA5F3F64A8F11FDD199F
+    4 : 0xAB040291BAFDE1BD13A36FA243E9CECAED92266C29EC1FBAACC99A6AACE51E3E
@@ -10,2 +10,2 @@
-    7 : 0xF6EC92324515892DBA62227C794696324EA38639C60EF94F138B59C0270B0C8A
-    8 : 0x3723C256E87505FC1174CCA1F71BC8145D8703D46AE88C88537DF9AF72E2AB0B
+    7 : 0xE622A16146FB45989BFEAC7FE32B961A933269E78F111B7DB61CE1B37F54BA40
+    8 : 0x9D64BE0CC068CED5CD0ACA32C101B4270B75B61DA74293245E1394C39B62FCA9

TPM2にLUKS2の鍵を入れる

ようやく本題です。LUKS2でボリュームを暗号化している場合、ブート時にパスフレーズの入力が必要です。ところでLUKS2はパスフレーズとは別の鍵をキースロットに複数登録することができ、それを使った複合が可能です。この鍵をTPM2に入れておき、ブート時に参照することでパスフレーズの入力を省略します。

まずはTPMが利用可能かどうかを調べます。調査などに便利なのでtpm2-toolsパッケージもインストールしておきます。

$ journalctl -k --grep=tpm

$ sudo pacman -S tpm2-tools
$ sudo tpm2_getrandom 20
$ sudo tpm2_getcap pcrs
$ sudo tpm2_pcrread sha256

TPM2を一般ユーザーでも利用したい場合はtssグループに参加させると使えます。

$ sudo homectl update --member-of=wheel,input,tss lufia

(いちどログアウトしてから)

$ tpm2_getrandom 20

systemd-cryptenroll

TPM2とLUKSキースロットの管理はsystemd-cryptenrollで行います。同様のことを行うプログラムにclevisもありますが、ここではsystemd-cryptenrollを選びました。この記事では言及しませんが、systemd-cryptenrollはTPM2に限らず、FIDO2を使った複合などにも対応しています。

systemd-cryptenrollの使い方ですが、ここまで進めているならそれほど難しくはなく、決まった順番にコマンドを実行するだけです。まずはTPM2デバイスの名前を調べます。

$ systemd-cryptenroll --tpm2-device=list
PATH        DEVICE     DRIVER
/dev/tpmrm0 IFX0785:00 tpm_tis

ここでデバイスの名前は/dev/tpmrm0と分かりました。上記の出力例では、TPMバイスは1つしかないのでautoとすることで省略可能です。あとはsystemd-cryptenrollコマンドに暗号化したディスクを与えましょう。

$ sudo systemd-cryptenroll /dev/nvme0n1p2 --tpm2-device=auto --tpm2-pcrs=4+7

ブート時にTPM2へアクセスするため、/etc/mkinitcpio.confMODULE変数にTPM2ドライバを追加します。

--- mkinitcpio.conf.orig 2022-01-29 16:55:28.098086104 +0900
+++ mkinitcpio.conf   2022-01-31 01:24:39.829166470 +0900
@@ -4,7 +4,7 @@
 # run.  Advanced users may wish to specify all system modules
 # in this array.  For instance:
 #     MODULES=(piix ide_disk reiserfs)
-MODULES=()
+MODULES=(tpm_tis)
 
 # BINARIES
 # This setting includes any additional binaries a given user may

あとはinitramfs-linux.imgをビルドして終わりです。

$ sudo mkinitcpio -p linux

これで次回以降のパスフレーズ入力が省略できます。

PCRハッシュ値に変化があった場合

上記ではPCR4とPCR7どちらかに変化があったとき鍵を無効にする設定でコマンドを実行しました。そのため、例えばカーネルが更新されたときはセキュアブートによってMOKも更新されることになり、PCRの値も変化して鍵が無効になります。この場合はTPM2から鍵を取り出せなくなるため、パスフレーズの入力が必要です。

パスフレーズでブートした後、以下のコマンドによって再びパスフレーズを省略できます。LUKS2のキースロットは有限ですし、TPM2から古い鍵を取り出す手段は失われているので、--wipe-slot=tpm2オプションを忘れないようにしましょう。

$ sudo systemd-cryptenroll /dev/nvme0n1p2 --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=4+7

参考リンク

*1:少なくとも他の方法に比べたら...

*2:赤くエラーが表示されるので少し驚くかも

*3:sealと表現される

*4:tpm2_pcrreadコマンドで確認できる

業務端末としてLinuxデスクトップを使うために設定したこと

2021年の11月に、業務端末としてDELL XPS 13を購入して、Linuxデスクトップに移行しました。いまでは快適に使えるようになりましたが、Linuxデスクトップに慣れていないこともあって思ったように動かず困ったところがあったので、導入にあたって悩んだところをまとめました。

ディスクの暗号化

業務利用の要件にディスクの暗号化があるので、bootパーティションを除いて暗号化しました。手順は過去記事に追記しました。

blog.lufia.org

GNOME

KDE Plasmaの方がスタイルは好みですし、実際に業務端末でも2ヶ月ほど使っていましたが、Wayland環境ではタッチパッドの左右スワイプが動かないとか、XWaylandで動作するアプリケーションを4Kディスプレイで表示するとぼやけた表示になるなど厳しいなと思いました*1。個人で使うものなら、少し効率が悪い程度なら問題にしませんが、業務で利用する場合は些細なことに気をとられたくないので、GNOMEを使うことにしました。

$ sudo pacman -S noto-fonts-cjk
$ sudo pacman -S gnome-shell gnome-control-center gnome-terminal gdm nautilus
$ sudo systemctl enable gdm.service

基本的にはデフォルトで使う派ですが、どうしても外せない設定を入れます。これらの意味は過去記事に書きました。

$ gsettings set org.gnome.desktop.input-sources xkb-options "['lv3:ralt_switch', 'ctrl:nocaps']"
$ gsettings set org.gnome.desktop.interface gtk-key-theme Emacs
$ gsettings set org.gnome.desktop.interface gtk-enable-primary-paste false
$ gsettings set org.gnome.desktop.peripherals.touchpad tap-and-drag-lock true

必要なGNOMEアプリケーションを追加します。ここではsushi*2evince*3を追加します。これらはFlathubにもあるので、flatpakで入れてもいいかも。

# pacmanでインストールする場合
$ sudo pacman -S sushi evince eog

# flatpakでインストールする場合
$ flatpak install org.gnome.NautilusPreviewer
$ flatpak install org.gnome.Evince
$ flatpak install org.gnome.eog

Google Meetの画面共有などに必要なのでxdg-desktop-portalも入れます。

$ sudo pacman -S xdg-desktop-portal-gnome

GNOMEの壁紙

/usr/share/backgroundsまたは~/.local/share/backgroundsにファイルを置いておくと設定アプリから変更できるようになります。

GNOMEテーマの変更

GNOMEのデフォルトテーマは、あまり好みではないので変更します。GNOMEにおいて、テーマと名前のつくものは色々あります。

アイコンテーマを変更すると全体的にアイコンが変わります。GTKテーマを変更すると、アプリケーションの外観が変わります。GNOMEシェルテーマは少し分かりづらいですが、トップバーやアクティビティ画面などが該当します。

テーマはGnome Lookなどを探せば本当に色々ありますが、多すぎて逆に好みのものを見つけるのが困難ですね。今のところ、目に入ったなかではFlat-Remixが良いかなと思ったのでこれを設定します。

アイコンテーマの変更

AURにflat-remixがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix.git
$ cd flat-remix
$ makepkg -si
$ gsettings set org.gnome.desktop.interface icon-theme Flat-Remix-Blue-Light

GTKテーマ

AURにflat-remix-gtkがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix-gtk.git
$ cd flat-remix-gtk
$ makepkg -si
$ gsettings set org.gnome.desktop.interface gtk-theme Flat-Remix-GTK-Blue-Light

GNOMEシェルテーマ

シェルテーマを変更するにはUser Themes拡張が必要なので先に用意しておきます。GNOME Extensionsに色々な拡張があるのでそこから追加してもいいのですが、gnome-shell-extensionsパッケージにも含まれているのでそちらを使いました。

# pacmanでインストールする場合
$ sudo pacman -S gnome-shell-extensions

# flatpakでインストールする場合
$ flatpak install org.gnome.Extensions

テーマ自体は、AURにflat-remix-gnomeがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix-gnome.git
$ cd flat-remix-gnome
$ makepkg -si
$ gsettings set org.gnome.shell.extensions.user-theme name Flat-Remix-Blue-Light

補足

上の手順ではAURからテーマをインストールしましたが、ユーザー単位でテーマを管理したい場合は~/.local/share以下にファイルを置くとgsettingsでテーマとして参照できるようになります。

$ mkdir -p ~/.local/share/{themes,icons}
$ mv themes/Flat-Remix-Blue-Light ~/.local/share/themes
$ mv themes/Flat-Remix-GTK-Blue-Light ~/.local/share/themes
$ mv icons/Flat-Remix-Blue-{Dark,Light} ~/.local/share/icons # LightからDarkへsymlinkしているので両方必要

指紋認証

XPS 13は指紋認証が使えるので設定します。ただし、2021年12月時点のfprintdパッケージは1.94.1でしたが、fprintdの1.92.0以上を使うと、

Impossible to enroll: GDBus.Error:net.reactivated.Fprint.Error.NoSuchDevice: No devices available

というエラーで指紋の登録ができません。journalctl -u fprintd.serviceすると、

Failed to clear storage before first enrollment: Device has no storage.

と記録があります。Dell XPS 13(9310) - ArchWikiによると、fprintd-1.92.0-1より新しいバージョンには問題があるらしいので、以下から1.92.0より古いパッケージを落としてくる必要がありました。

$ sudo pacman -U fprintd-1.90.9-1-x86_64.pkg.tar.zst

問題が解決するまではfprintをアップデートされると困るので、/etc/pacman.confで固定します。

--- /etc/pacman.conf.orig    2022-01-08 18:13:06.000000000 +0900
+++ /etc/pacman.conf  2022-01-16 23:03:32.302176992 +0900
@@ -22,7 +22,7 @@
 Architecture = auto
 
 # Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup
-#IgnorePkg   =
+IgnorePkg    = fprintd
 #IgnoreGroup =
 
 #NoUpgrade   =

また、ドライバも必要です。XPS 13では以下の2つが必要です。

  • libfprint-tod-git
  • libfprint-2-tod1-xps9300-bin

どちらもAURからインストールします。

$ git clone https://aur.archlinux.org/libfprint-tod-git.git
$ cd libfprint-tod-git
$ makepkg -si

$ git clone https://aur.archlinux.org/libfprint-2-tod1-xps9300-bin.git
$ cd libfprint-2-tod1-xps9300-bin
$ makepkg -si

指紋で認証する

まず、指紋を登録するユーザーはinputグループに属している必要があるので、所属していない場合はグループに追加します。

$ sudo homectl update --member-of=wheel,input lufia

準備ができたら指紋を登録します。fprintd-enrollを実行するとユーザーのパスワードを聞かれるので、入力して指紋を登録します。ここでsudo fprintd-enrollのようにsudoを使ってしまうと、rootユーザーの指紋として扱われてしまいます。

$ fprintd-enroll

ここで登録した指紋は/var/lib/fprintに保存されます。登録した指紋を削除する場合はfprintd-deleteを使います。

$ fprintd-delete root

あとは必要に応じてPAMの設定を変更すれば終わりです。

  • /etc/pam.d/sudo
  • /etc/pam.d/sddm
  • /etc/pam.d/kde

KDE Plasma Desktopを使っている場合、ログイン時に参照するのは/etc/pam.d/sddmで、画面ロックの解除で参照するのは/etc/pam.d/kdeです。GDMでは特に変更しなくても指紋が登録されていれば有効になります。

上記のArchWikiでは、lsusbコマンドでデバイスを確認していますが、このコマンドはusbutilsパッケージに含まれています。

サウンド

ALSAとPulseAudioは何が違うのかについて、ざっくりとALSAとPulseAudioの関係がとても分かりやすいです。ところで最近はPulseAudioを置き代えるためにPipeWireというものが実装されていて、PipeWire, the media service transforming the Linux multimedia landscapeによると、ALSAやPulseAudio互換モードを持ち、個別のアクセス制御やパフォーマンスなど優位らしいのでPipeWireを使います。

$ sudo pacman -S alsa-firmware sof-firmeware
$ sudo pacman -S pipewire-pulse pipewire-alsa pipewire-jack

実行すると、pulseaudio, pulseaudio-alsa, pulseaudio-bluetoothなどが削除されますが、pipewireが代わりになるので今まで通り動作します。

PulseAudioもPipeWireもどちらもサウンドサーバとしてデバイスとアプリケーションの中間で動作しますが、PipeWireはセッションを開始するためにセッションマネージャーを必要とします。現在、セッションマネージャーはpipewire-media-sessionwireplumberのどちらかを使います。pipewire-media-sessionのドキュメントに

Note that we recommend the use of WirePlumber instead.

とあるのでwireplumberをインストールします。

$ sudo pacman -S wireplumber

ただし、2021年12月時点では、KDE PlasmaのWaylandセッションでWirePlumberを使うと、音がまったく鳴らないし動画も再生されない(一瞬だけ鳴るけどそのまま詰まって動かなくなっているような動きをする)*4問題がありました。この場合はpipewire-media-sessionを使いましょう。

$ pacman -S pipewire-media-session

Flatpak

Flatpakはディストリビューションに依存しないパッケージ管理システムです。主にアプリケーションの配布に使われていて、Flatpakにより管理されたアプリケーションはサンドボックスで動作するなどの特徴があります。

$ sudo pacman -S flatpak
$ flatpak --if-not-exists remote-add flathub https://flathub.org/repo/flathub.flatpakrepo

Flatpakには各種ブラウザ、Visual Studio Code、Slack、Discord、Zoomなど人気のあるアプリケーションはだいたい登録されています。

pacmanflatpakパッケージをインストールすると/etc/profile.d/flatpak.shが作られて、ここに記述されたXDG_DATA_DIRSが次のログイン以降に反映されます。GNOMEKDE PlasmaなどのデスクトップはXDG_DATA_DIRSにあるアプリケーションやテーマを認識するので、Flatpakでインストールしたアプリケーションであっても他のものと同様に起動できるようになっています。または、Flatpakのアプリケーションをコマンドラインから起動する場合は以下のようになります。

$ flatpak run com.example.app

不要になったアプリケーションはflatpak uninstallで削除できますが、アプリケーションが依存していたランタイムはそのまま残ります。どこからも参照されないランタイムも削除する場合は以下のように実行します。

$ flatpak uninstall com.example.app
$ flatpak uninstall --unused

Visual Studio Code

Goのコードを書くときはAcmeを使っていますが、TypeScriptなどはVisual Studio Codeを使っているのでFlatpakでインストールします。ここではflathubを明示していますが、IDが一意に定まるなら省略しても動作します。

$ flatpak install flathub com.visualstudio.code

Visual Studio Live Shareを使う場合はgnome-keyringが必要です。これはFlatpakには無いのでpacmanで入れましょう。

$ sudo pacman -S gnome-keyring

FlatpakアプリケーションをWaylandセッションで動作させる

Waylandセッションで起動したい場合は--socket=waylandオプションが必要です。都度flatpak runに与えるか、flatpak overrideで永続化して使いましょう。flatpak overrideした設定は、~/.local/share/flatpak/overridesまたは/var/lib/flatpak/overridesに書かれます。

$ flatpak run --socket=wayland com.example.app

$ flatpak --user override --socket=wayland
$ flatpak run com.example.app

ElectronアプリケーションをWaylandセッションで動作させる(問題あり)

Electronアプリの場合は、上記に加えてこれらのオプションも必要です。

$ flatpak run --socket=wayland com.example.app --enable-features=UseOzonePlatform --ozone-platform=wayland

これらのオプションは、~/.config/electron-flags.confに書くと参照するらしいのですが手元で試した限りでは反映されなかったので、~/.local/share/applications以下に.desktopファイルを作って、--enable-featuresを含めたコマンドを設定しておくといいでしょう。

また、Electronアプリは、Chromiumの問題によりWaylandセッションでは日本語入力ができません。Waylandで動くタイル型ウィンドウマネージャ・swayを使うによると2021年8月頃から発生していて、2022年1月でもまだ継続しているので、ブラウザやエディタではとても困りますね...

ブラウザ

2022年1月時点では、ChromiumはFlathubに登録されていますが、Google Chromeはありません。ただしFlathubのβチャンネルにChromeがあるので、これを使います。また、Microsoft Edgeもβチャンネルにあるので一緒にインストールしました*5

$ flatpak --user remote-add --if-not-exists flathub-beta https://flathub.org/beta-repo/flathub-beta.flatpakrepo
$ flatpak install com.google.Chrome
$ flatpak install com.microsoft.Edge

flatpak remote-add/var/lib/flatpak以下への書き込み権限を必要としますが、--userオプションを加えるとユーザー個別に設定させることができます。

Flatpakアプリケーションが一定時間で強制終了する

最初にアプリケーションを起動してしばらくすると、

background activity:

という通知が表示されることがあります。ここでAllowを選ぶことができますが、選ばずに閉じるなどをすると、アプリケーションが1分*6で落ちるようになります。アプリケーションがバックグラウンドに遷移したときに動作させたままにするかどうかの確認なんですが、厄介なことに、いちど閉じると再通知されません。

誤って通知を閉じてしまった場合、flatpak permission-resetでリセットできます。また、現在の設定はflatpak permissionsで確認でき、パーミッションを個別にセットする場合はflatpak permission-setです。

# 確認する
$ flatpak permissions

# リセットする
$ flatpak permission-reset com.example.app

# 個別にセットする
$ flatpak permission-set background com.example.app yes

ところで、このパーミッションは、インストールする際に表示されるパーミッションとは異なるものらしいです。アプリケーションが要求するパーミッションはインストール直前に表示されます。または、以下のコマンドで確認できます。

$ flatpak info --show-permissions com.example.app

Flatpakアプリケーションのデバッグ

--commandオプションで、実行するコマンドを切り替えられます。

$ flatpak run --command=sh com.example.app

アプリケーションのコンテナでシェルが動作するので、あとはデバッグ対象のアプリケーションを実行するといいです。

Flatpakとpacmanの使い分け

pacmanflatpakのどちらにも同じアプリケーションが利用可能になっている場合、どちらを使うのか迷いますが、個人で使うものはflatpakで、システム全体で持っている方が都合よいものはpacmanを使うといいのかなと思っています。

*1:Flatpakのところに書いているけどElectronアプリをWaylandで動かすのは厳しい

*2:macOSでのQuick Look

*3:macOSでのプレビュー

*4:https://twitter.com/plan9user/status/1472962934741823492

*5:Firefoxは普段使うブラウザなのでpacmanでインストールしていた

*6:時間は設定によるがデフォルトでは1分

I/O errorでユーザーにログインできなくなった

普段使っているユーザーでLinuxデスクトップにログインできなくなった。正しいパスワードを入力すると画面が1秒程度切り変わるけど、すぐディスプレイマネージャのログイン画面に戻ってしまう状態だった。

エラーの内容を調べる

rootではログインできたので、Ctl+Alt+F4などでディスプレイマネージャからコンソールに切り替えて、ログインできなくなった原因を調べる。問題のユーザーはsystemd-homedでLUKSイメージを使った環境だったので、activateしてみるとI/Oエラーが出力された。

# homectl activate lufia
[  152.810994] loop0: detected capacity change from 0 to 764010613
[  152.812280] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x80700 phys_seg 1 prio class 0
[  152.812446] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.812554] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  152.813969] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.814210] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  152.815182] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.815344] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  153.564587] device-mapper: table: 254:0: len=763977845 not aligned to h/w logical block size 4096 of loop0
[  153.564591] device-mapper: core: Cannot calculate initial queue limits
[  153.564716] device-mapper: ioctl: unable to set up device queue for new table.
[  153.568854] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  153.569169] Buffer I/O error on dev loop0, logical block 95501326, async page read

ここで

blk_update_request: I/O error, dev loop0, sector xxx

という嫌なエラーがみえる。また、homectl inspecthomectl listなどで現在の状態を調べると、dirtyという状態になっていることがわかる。systemd-homedはdeactivateせず終了したとき、dirty状態になるらしい*1

# homectl list
NAME  UID   GID   STATE REALNAME HOME        SHELL
lufia 60331 60331 dirty lufia    /home/lufia /bin/bash

1 home areas listed.

このフラグは、/home/[user].homeにあるLUKSイメージにhome-dirty=1として属性が付けられている。属性を消せばinactive状態に戻る。

# attr -l /home/lufia.home
Attribute "home-dirty" has a 1 byte value for /home/lufia.home

# attr -r /home/lufia.home home-dirty

systemd-homedのLUKSストレージ構造

systemd-homedはディレクトリやCIFSサーバなどにストレージを切り替えられるようになっていて、LUKS+Btrfsなファイルシステムイメージがデフォルトとして使われる。この場合、以下の要素が使われる。

LUKSストレージでユーザーが作られると、ユーザーのホームディレクトリは/home/[user].homeという名前のイメージファイルで管理されるようになる。イメージファイルはそのままではマウントできないので、device-mapperでイメージをloopデバイスにする。このデバイスは、LUKSで暗号化したうえでBtrfsファイルシステムとしてフォーマットされる。こうして作られたファイルシステムはログインするとsystemd-homedによって/home/[user]へマウントされるので、ユーザーのデータは最終的に[user].homeのイメージファイルへ書かれることになる。

データを取り出す

ローカルには紛失すると困るものはないはずだが、作業中のソースコードなどは救出したいので必要なデータを取り出した。systemd-homed - ArchWikiにレスキューコマンドがあったのでそのまま使う。

# losetup -fP lufia.home
# cryptsetup open /dev/loop0p1 rescue
# mount /dev/mapper/rescue /mnt/

これで/mnt/[user]からファイルを参照できるようになるので、あとはtarなどで必要なファイルをコピーする。それが終わったら新しくユーザーを作って、逃がしておいたファイルを戻せばよい。ここでは、新しいユーザーはLUKSイメージを使わないストレージにした。

# userctl create --storage=directory --uid=60331 --member-of=wheel lufia

暗号化はディスク全体でやればいいし、Btrfsディスクイメージの利点もあまり感じていなかった。

コマンドとsystemd-homedの違い(未解決)

レスキュー用のコマンドではマウントできるのに、systemd-homedではactivateできないところが不思議だったのでjournalctlでログを読む。

# journalctl -u systemd-homed --since=today
Jan 03 10:49:54 plage systemd-homed[359]: lufia: changing state inactive → activating
Jan 03 10:49:54 plage systemd-homework[506]: Provided password unlocks user record.
Jan 03 10:49:54 plage systemd-homework[506]: Successfully locked image file '/home/lufia.home'.
Jan 03 10:49:54 plage systemd-homework[506]: Backing file is fully allocated already.
Jan 03 10:49:54 plage systemd-homework[506]: Setting up loopback device /dev/loop0 completed.
Jan 03 10:49:55 plage systemd-homework[506]: device-mapper: reload ioctl on home-lufia (254:0) failed: Invalid argument
Jan 03 10:49:55 plage systemd-homework[506]: Failed to unlock LUKS superblock: No such device
Jan 03 10:49:55 plage systemd-homed[359]: block device /sys/devices/virtual/block/loop0 has been removed.
Jan 03 10:49:55 plage systemd-homed[359]: Activation failed: No such device
Jan 03 10:49:55 plage systemd-homed[359]: lufia: changing state activating → inactive

ここで、

block device /sys/devices/virtual/block/loop0 has been removed.
Activation failed: No such device

とあるのは、systemd-homedの処理途中でエラーが発生した場合にloopデバイスが残らないように削除しているだけなので問題ではない。なのでおそらく

device-mapper: reload ioctl on home-lufia (254:0) failed: Invalid argument

が、レスキュー用のコマンドとの差異だろう。次の行に出力されたログから、

のどこかでioctlがエラーになっているのだと思うが、簡単には追えそうになかったので諦めた。

参考情報

調べているときに参考になった記事。

Gitで9legacyのパッチを管理する

9legacyは「ベル研Plan 9最終版からのパッチ集」という位置付けです。更新がある場合、基本的には新しいパッチが追加されていきますが、パッチにバグがあった場合などはパッチ自体が更新されることもあります。そのため、手元の環境にパッチを適用したあと、次の更新を手元に適用するためには、過去に適用したパッチを元に戻してから再適用するなどの仕草が必要です。そのため現在は、直接パッチを手元の環境に適用するのではなく、9legacyが配布しているCDイメージを使って replica/pull する方法が推奨されています。

とはいえ自分自身でもPlan 9に手を入れているので、replica/pull で書き戻ってしまうと都合が悪いなどの事情があって、今はGitで差分を管理しています。この記事では、そのための手順をまとめます。

前提

事前に、以下2つのQEMUディスクイメージを用意しておきます。

disk1.rawベル研Plan 9を9legacyの安定版にアップデートするで、

インストールが終わったら、あとで使うためにインストール直後のdisk0.rawをバックアップしておきましょう。ここでは disk0-orig.raw とします。

と書いているところでバックアップしたディスクイメージです。この記事では説明の都合上 disk1.raw と呼称します。このディスクイメージは何度もインストール直後へ戻すことになるので、バックアップは残しておきましょう。

必要なツールのインストール

disk0.raw で起動して、Gitをインストールします。

$ ./start.bash -v
qemu-system-x86_64 -m 1G -smp 2 \
    -device virtio-scsi-pci,id=scsi \
    -device scsi-hd,drive=hd0 \
    -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,if=none,index=0 \
    -device virtio-net-pci,netdev=ether0 \
    -netdev user,id=ether0,net=10.0.2.0/24 \
    -machine type=pc,accel=hvf

386環境で使えるGitのバイナリは以下からダウンロードできるので、$home/bin/386 以下などに展開しておきましょう。

  • lufia.org/git-386.tgz

また、パッチの管理を簡単にするため、9legacy-toolもインストールしておくと便利だと思います。

9legacyの差分をGitリポジトリに書き出す

準備ができたら、disk0.rawdisk1.raw を接続した状態で仮想マシンを起動します。

$ ./start.bash -v -a disk1.raw 
qemu-system-x86_64 -m 1G -smp 2 \
    -device virtio-scsi-pci,id=scsi \
    -device scsi-hd,drive=hd0 \
    -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,if=none,index=0 \
    -device scsi-hd,drive=hd1 \
    -drive file=disk1.raw,format=raw,cache=writethrough,id=hd1,if=none,index=1 \
    -device virtio-net-pci,netdev=ether0 \
    -netdev user,id=ether0,net=10.0.2.0/24 \
    -machine type=pc,accel=hvf

起動直後は disk1.raw をマウントしていないので、disk1.raw/n/other にマウントします。

% con -l /srv/fscons
prompt: fsys other config /dev/sd01/fossil # virtioが無効な場合はsd01をsdC1に読み替える
prompt: fsys other open -AWPV
prompt: srv fossil
prompt: (ctl+\)
>>> q

% mount -n -c /srv/fossil /n/other other/active

これでインストール直後のPlan 9ファイルツリーを /n/other でアクセスできるようになります。以降は、このファイルツリーにパッチを適用していきます。

Gitリポジトリの準備(最初だけ)

ここで用意するもの

まずリポジトリを作成します。Gitのワーキングツリーは /n/other をそのまま使うので、Gitディレクトリ(ベアリポジトリ)だけ必要です。

% cd $home/lib
% mkdir dist.git
% git -C dist.git init --bare

# 必要なら
% git remote add origin https://github.com/xxx/xxx

dist.gitignore を準備します。ファイルの内容はgithub.com/lufia/plan9:.gitignoreです。/n/other は9legacyの更新があるたびにリセットするので、ここではまだ/n/other/.gitignore には置きません。

9legacyパッチの適用

9legacyのパッチを、/n/other 以下のファイルツリーに適用します。

% mkdir /tmp/20211208 # 作業用ディレクトリなので、どこでもいい
% cd /tmp/20211208
% 9legacy/init -r `{pwd}
% bind -c /n/other plan9
% 9legacy/update -r `{pwd}
% 9legacy/stable -r `{pwd} >o
% 9legacy/installall -n -r `{pwd} o
% cp $home/lib/dist.gitignore plan9/.gitignore

# libmemdraw-9kはalloc.cが1つのパッチで2回更新されるので、patchのテストが落ちる
# そのためテストを無視して適用する
% 9legacy/install -n -r `{pwd} -f libmemdraw-9k

# 残りのパッチを適用
% 9legacy/installall -n -r `{pwd} o

次に9kカーネルのパッチを当てます。

% 9legacy/9k -r `{pwd} >o
% 9legacy/installall -n -r `{pwd} o

必要なら、9legacyのページでOptionalとされているパッチを当てます。

% 9legacy/install -n -r `{pwd} libmux
% 9legacy/install -n -r `{pwd} libworker

ここまで終われば、/tmp/20211208 の作業ディレクトリは不要なので消しても問題ありません。

% unmount plan9 # unmountせずrmするとdisk1.rawのファイルツリーが消えてしまうので注意

差分をリポジトリにコミット

Gitディレクトリ(ベアリポジトリ)とワークツリーを分けた関係で、--git-dir--work-tree オプションを与えてあげる必要があります。何度も入力するのは面倒なのでシェル関数を作っておきます。

% fn x { git --git-dir $home/lib/dist.git --work-tree /n/other $* }

オプションの代わりに環境変数でも代用できます。

GIT_DIR=$home/lib/dist.git
GIT_WORK_TREE=/n/other

これで、前回のリポジトリ状態と /n/other の差分を扱えるようになるので、必要な変更をGitリポジトリに追加しましょう。

% cd /n/other
% x status
% x add path/to/file # 内容をみてgit addする
% x commit -m '2021-12-08'

# 必要なら
% x push

全部終わったら片付け。

% unmount /n/other

次回以降、9legacyのパッチが更新されたときは、disk1.raw をインストール直後のディスクイメージに戻して上記の手順を繰り返すと、差分だけをGitで管理できます。以下リポジトリstable ブランチに、9legacyの変更をpushしているので必要なら使ってください。

github.com

リポジトリの内容をPlan 9へ反映する

これで、9legacyのパッチを戻さなくても必要な差分を扱えるようになったので、次にGitリポジトリを使って手元の環境を更新する手順を紹介します。ここでは、アップデートしたいPlan 9のディスクを disk2.raw とします。

disk2.raw を起動ディスクとしてブートした後、上記で作成したリポジトリをcloneします。当然ですがGitコマンドが必要なので、disk1.raw の時と同様にインストールしておいてください。

//memo: rc; rfork ne; bind -a /usr/lufia/bin/386 /bin

% cd $home/lib
% git clone -b stable https://github.com/lufia/plan9.git

2回目以降はpullで更新します。

% cd $home/lib/plan9
% git pull origin stable

次に、Plan 9のルートディレクトリには、実行中にしか存在しない仮想的なファイル*1が含まれるので、それらを除いた disk2.raw の実態を /n/boot から参照できるようにします。

% echo 'srv -AWP replica' >>/srv/fscons # -Vオプションは不要

# 少し待ってからマウントする
% mount -c /srv/replica /n/boot

これで以下の準備ができました。

  • $home/lib/plan9
    • 最新の9legacyパッチを適用したリポジトリ(上の手順で作ったもの)
  • /n/boot
    • disk2.raw に保存されている素のファイルツリー

あとは git を使って、変更のあるファイルをリポジトリの状態にリストアしていけば、最終的には最新の9legacyファイルツリーへ更新できます。

% fn x { git --git-dir $home/lib/plan9/.git --work-tree /n/boot $* }
% cd /n/boot
% x status
% x restore .gitignore
% x restore path/to/file # 必要なだけ繰り返す

最後に片付けして終わり。

% unmount /n/boot
% rm /srv/replica

新しいソースコードでリビルド

あとは以下の記事と同じです。「ソースコードのリビルド」から先を参照してください。

blog.lufia.org

気になる人のためのメモ

Gitでリストアしたファイルのオーナーやグループは、リストアしたユーザーのものに切り替わります。一般的に、/sys 以下のオーナーは sys ユーザーなので、この差が気になる人は chgrp -u などで個別に変更するといいです。

うまくいかない場合

2021年末の時点で、Plan 9に移植したGitコマンドは(特にpull/pushでハングするなど)やや不安定です。そういった場合、以下の環境変数をセットしておくとトレースログとそれぞれの実行にかかった時間などを表示するので原因の調査に役立ちます。

*1:/mnt/dev など

Goの型や関数定義を検索するコマンド

9fansを見ていると、Rob Pike氏が、Goの型定義や関数定義を検索するコマンドを紹介していた。

実装

上記によるとコマンドは4つ。

bin/f

カレントディレクトリにあるソースコードから関数定義をgrepする。

#!/bin/sh

9 grep -i -n '^func (\([^)]+\) )?'$1'\(' *.go /dev/null

bin/t

f と似ているけど、こちらは型定義をgrepする。

#!/bin/sh

9 grep -i -n '^type '$1' ' *.go /dev/null

bin/cf

codesearch インデックスからGoの関数定義をgrepする。

#!/bin/sh

csearch -n -f '\.go$' '^func (\([^)]+\) )?'$1'\('

bin/ct

同様に、こちらは型定義。

#!/bin/sh

csearch -n -f '\.go$' '^type '$1

使い方

上記の f または t には、plan9portのgrep*1が使われている。Plan 9grep+|が使える実装なので、インストールしたうえでPATHの末尾に追加しておく必要がある。plan9portのインストールについてはPlan 9 from User Space(plan9port)を使うで書いた。または、試していないけど大体は同じなので、grep -Eで代用できるかもしれない。

出力例はこんな雰囲気。全て filename.go:line 形式で結果を出力するので、Acmeエディタを使っていれば右クリックするだけで該当行にジャンプできるようになっている。

% f String
pickle.go:28: func (op Operator) String() string {
pickle.go:65: func (e *Expr) String() string {
pickle.go:79: func (r *Rule) String() string {
pickle.go:110: func (m *Metric) String() string {
pickle_test.go:336: func (p *InvalidData) String() string {

% t Metric
pickle.go:103: type Metric struct {

cf または ct では csearch コマンドが使われている。これはgoogle/codesearchで公開されているコマンドで、事前にインデックスを作成しておき、インデックスを正規表現で検索する。

# cgrep, cindex, csearchをインストール
% go install github.com/google/codesearch/cmd/...

# インデックス化したいディレクトリを列挙する
% cindex $GOPATH/src ...

これでcsearchすると、インデックスを検索できるようになる。作成したインデックスは、デフォルトでは$HOME/.csearchindexに作られるが、CSEARCHINDEX環境変数で変更できる。技術的な詳細は、Russ Cox氏が以下に書いているが、難しいので読めていない。

*1:9 grepの部分がそれ

labstack/echoのv4.6でRequestLoggerWithConfigを使う

labstack/echoには、以前から middleware.LoggerWithConfig が存在していて、リクエストログのカスタマイズがある程度は可能でした。公式のLogger Middlewareドキュメントより引用します。

e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
    Format: "method=${method}, uri=${uri}, status=${status}\n",
}))

この Formatfasttemplate で処理されます。テンプレートの変数はレイテンシやリクエストサイズ、IPアドレスなどミドルウェア側で用意されている値を選択することはできますが、アプリケーション側で用意した任意の値を出力させることができませんでした。また、 io.Writer で出力先の変更はできますが、io.Writer インターフェイスを満たさない任意のロガーを使うことはできませんでした。なので任意の値を追加するためには、独自のミドルウェアを実装して、LoggerWithConfigが行っている処理と同じようにリクエストログで必要な値を echo.Context から計算する必要がありました。

RequestLoggerWithConfig

echoのv4.6で middleware.RequestLoggerWithConfig が追加されて、リクエストログのカスタマイズが少しだけ簡単になりました。以下に例を示します。

package main

import (
    "log"

    "github.com/go-logr/stdr"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

var logger = stdr.New(log.Default())

func writeRequestLog(c echo.Context, v middleware.RequestLoggerValues) error {
    logger.Info("finished",
        "method", v.Method,
        "path", v.URIPath,
        "remote_ip", v.RemoteIP,
        "user_agent", v.UserAgent,
        "protocol", v.Protocol,
        "status", v.Status,
        "latency", v.Latency,
        "content_length", v.ContentLength, // ContentLengthの型はstringで、GETの場合は空文字列
        "response_size", v.ResponseSize,
    )
    return nil
}

func requestLogger() echo.MiddlewareFunc {
    return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
        LogValuesFunc:    writeRequestLog,
        LogMethod:        true,
        LogURIPath:       true,
        LogRemoteIP:      true,
        LogUserAgent:     true,
        LogProtocol:      true,
        LogStatus:        true,
        LogLatency:       true,
        LogContentLength: true,
        LogResponseSize:  true,
    })
}

func main() {
    e := echo.New()
    e.Use(requestLogger())
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "hello")
    })
    e.Start(":8080")
}

これで、任意のロガーを通してアクセスログを出力できるようになりました。middleware.RequestLoggerValues で必要な値に対応する middleware.RequestLoggerConfig のフラグを true にセットしておかないとゼロ値になってしまうので、値がおかしい場合は見直してみましょう。

echo.Context.Logger

ところで echo.Context にはリクエストログとは別に

type Context interface {
    Logger() echo.Logger
}

が用意されていて、アプリケーションで自由に使うことができます。この echo.Logger の実態はデフォルトだと gommon.Logger になっていて echo.Context.SetLogger で変更が可能です。ただし、echo.Logger インターフェイス

type Logger interface {
    Output() io.Writer
    SetOutput(w io.Writer)
    Prefix() string
    SetPrefix(p string)
    Level() log.Lvl
    SetLevel(v log.Lvl)
    SetHeader(h string)
    Print(i ...interface{})
    Printf(format string, args ...interface{})
    Printj(j log.JSON)
    Debug(i ...interface{})
    Debugf(format string, args ...interface{})
    Debugj(j log.JSON)
    Info(i ...interface{})
    ... この後もログレベルごとに3つメソッドが定義されている...

のようにとても大きくて、JSONログが必要なだけでも全部満たす必要があるのは不毛なので、もう少しこれもどうにかならないかなと思っています。

NodeJS+TypeScriptのchild_processモジュールで非同期にコマンドをパイプする

さいきん業務ではReactを使っていて、フロントに対する苦手意識も減ってきたので、いろいろ実験するための環境として自サイトをNextJSのSSGで生成するようにしたのですが、このとき、もともとはPlan 9mkfileでリソースを管理していた事情もあって、NodeJS+TypeScriptで複数の外部コマンドを扱う必要がありました。

NodeJSで非同期実行する場合、単純なコマンドならchild_processモジュールのspawnを使えばいいだけなのですが、

  • コマンドにファイル名を渡す以外の方法で標準入力にデータを流したい
  • コマンドの標準出力を、別コマンドの入力にしたい

などの場合に、どうすればいいのか分からなかったので調べました。ただ、最後にも書いているけどchild_processモジュールだけで実装するのは意外と大変なので、execaのようなライブラリを使ったほうがいいだろうと思います。

ファイルを指定して単一のコマンドを実行する

child_processモジュールにはコマンドを実行するための関数が複数ありますが、非同期で実行する場合はspawnを使います。コマンドの実行結果はStreamになっているので、以下のように書くと標準出力の内容を取得できます。

import { spawn } from "child_process";

async function main() {
    const p = await spawn("ls", ["-al"], {
        stdio: ["pipe", "pipe", "inherit"],
    });
    for await (const s of p.stdout){
        console.log(`${s}`);
    }
    const status = await new Promise((resolve, reject) => {
        p.on("close", resolve);
    });
    console.log("Status:", status);
}

main();

spawnstdioオプションは[(stdin), (stdout), (stderr)]の順になっていて、ここでpipeを与えるとプログラムから読み書きできるようになり、inheritの場合は親プロセスのファイルディスクリプタを共有します。上記コード例の場合は、stdinstdoutpipeなのでプログラムから読み書きできる状態でプロセスを実行しますし、stderrのところがinheritなので、コマンドのエラーは標準エラー出力にそのまま流れます。ただし、ここで実行しているlsコマンドは標準入力を読まないので、stdinpipeになっている意味はありません。

標準エラー出力を読む場合は、stdioオプションをpipeに設定して、p.stdoutの代わりにp.stderrを読めば得られます。

任意のストリームをコマンドに渡したい

コマンドの扱う入力がファイルで足りる場合はspawnだけで良いのですが、コマンドに流したいデータは必ずしもファイル名で与えられるとは限りません。例えばHTTPで取得したレスポンスかもしれないし、別のコマンドを実行した結果かもしれません。具体的には、

createReadStream("file").pipe(spawn("sed", ...)).pipe(spawn("wc", ...))

のようにStreamを繋げたい場合は、意外と素直に書くことができませんでした。spawnstdioオプションはStreamを受け取れるので、

import { spawn } from "child_process";
import { createReadStream } from "fs";

async function main() {
    const fin = createReadStream("package.json", "utf-8");
    const p = await spawn("wc", ["-l"], {
        stdio: [fin, "pipe", "inherit"],
    });
    ...
}

とすれば実現できるかというと、このコードはERR_INVALID_ARG_VALUEで失敗します。

$ npx ts-node main.ts 
TypeError [ERR_INVALID_ARG_VALUE]: The argument 'stdio' is invalid. Received ReadStream {
  path: 'package.json',
  flags: 'r',
  mode: 438,
  fd: null,
  start: undefined,
  end: Infinity,
  pos: undefine...
    at new NodeError (node:internal/errors:371:5)
    at node:internal/child_process:1042:13
    at Array.reduce (<anonymous>)
    at getValidStdio (node:internal/child_process:967:11)
    at ChildProcess.spawn (node:internal/child_process:354:11)
    at Object.spawn (node:child_process:698:9)
    at main (/home/lufia/Downloads/x/main.ts:6:18)
    at Object.<anonymous> (/home/lufia/Downloads/x/main.ts:18:1)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Module.m._compile (/home/lufia/Downloads/x/node_modules/ts-node/src/index.ts:1311:23) {
  code: 'ERR_INVALID_ARG_VALUE'
}

どうやらcreateReadStreamしただけではfinが内部に持っているファイルディスクリプタがまだnullのようです。fin.on("data", ...)すればファイルディスクリプタがセットされそうでしたが、それだと本当はコマンドに流したいデータがコールバック関数に流れてしまうので、意図していた動作にはなりません。

Transformを実装してパイプする

Stramには、読み込み用のReadableや書き込み用のWritable以外にも、ストリームを流れるデータを加工して流すためのTransformが用意されています。上でやりたかったコマンドのパイプも、このTransformを実装すると実現できます。Transformの実装方法は、コンストラクタに変換するための関数を渡す方法と、クラスとして実装する方法の2種類ありますが、ここでは前者で実装しました。

import { ChildProcess, spawn } from "child_process";
import { createReadStream } from "fs";
import { Transform } from "stream";

function createTransform(p: ChildProcess): Transform {
    const data: string[] = [];
    p.stdout?.on("data", s => {
        data.push(s); // 実行したコマンドの標準出力を貯めておく
    });

    const t = new Transform({
        // 定期的に呼ばれるので貯めていたデータを流す
        transform: (s, encoding, callback): void => {
            // 前プロセスから届いたデータをコマンドに流す
            p.stdin?.write(s);

            // コマンドの結果を次のストリームに渡す
            while(data.length > 0)
                t.push(data.shift());
            callback();
        },
        final: async (callback): Promise<void> => {
            // コマンドが終わるのを待つ
            p.stdin?.end();
            const status = await new Promise((resolve, reject) => {
                p.on("close", resolve);
            });
            if(status !== 0)
                throw new Error(`${p.spawnfile}: exit with ${status}`);

            // p.stdin.end()の後で貯まったデータを次のストリームへ全部流す
            while(data.length > 0)
                t.push(data.shift());
            callback();
        },
    });
    return t;
}

// 使用例
async function main() {
    const fin = createReadStream("package.json", "utf-8");
    const p = spawn("grep", ["ts"], {
        stdio: ["pipe", "pipe", "inherit"],
    });
    const t = createTransform(p);
    const stream = fin.pipe(t);
    try {
        for await (const s of stream){
            console.log(`${s}`);
        }
    } catch(e) {
        console.error(e.message)
    }
}

pipeではなくpipelineを使う

ところで、pipeでストリームを接続した場合、ストリームのうちどれか1つでもエラーが発生すると、残りのプロセスが回収されない問題があります。イベントを監視して適切な対応をすればいいのですが、接続するプロセスが多くなると面倒なので、今はpipelineを使うとよいそうです。

ただし、pipeと異なりpipelineは戻り値がPromise<void>になっているため、これまでのようにfor...ofを使った出力結果の取得ができません。代わりにpipelineでは、最後の引数にWritableなストリームを受け取るようになっていて、例えばファイルに書き出す場合はここへcreateWriteStreamで生成したストリームを渡します。

とはいえ、ここではコマンドの実行結果をプログラムから扱いたいだけなので、ファイルに書き出す必要はありません。流れてきたデータをメモリ上で保持するためのWritableなストリームを探したのですが見つからなかった*1ので、以下のようにWritableMemoryStreamを用意して対応しました。

import { ChildProcess, spawn } from "child_process";
import { createReadStream } from "fs";
import { Transform, Writable } from "stream";
import { pipeline } from "stream/promises";

async function main() {
    const fin = createReadStream("package.json", "utf-8");
    const grep = spawn("grep", ["ts"], {
        stdio: ["pipe", "pipe", "inherit"],
    });
    const wc = spawn("wc", ["-l"], {
        stdio: ["pipe", "pipe", "inherit"],
    });
    const w = new WritableMemoryStream();
    try {
        await pipeline(fin, createTransform(grep), createTransform(wc), w);
    } catch(e) {
        console.error(e.message)
    }
    console.log(w.toString());
}

function createTransform(p: ChildProcess): Transform {
    const data: string[] = [];
    p.stdout?.on("data", s => {
        data.push(s);
    });

    const t = new Transform({
        transform: (s, encoding, callback): void => {
            p.stdin?.write(s);
            while(data.length > 0)
                t.push(data.shift());
            callback();
        },
        final: async (callback): Promise<void> => {
            p.stdin?.end();
            const status = await new Promise((resolve, reject) => {
                p.on("close", resolve);
            });
            if(status !== 0)
                throw new Error(`${p.spawnfile}: exit with ${status}`);
            while(data.length > 0)
                t.push(data.shift());
            callback();
        },
    });
    return t;
}

class WritableMemoryStream extends Writable {
    private data: string[];

    constructor() {
        super();
        this.data = [];
    }

    _write(data: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
        this.data.push(data);
        callback();
    }

    toString(): string {
        return this.data.join("");
    }
}

Transformと同じように、Writableのコンストラクタを使ってもよかったけれど、toStringで最終的な結果を取得する方が自然に思えたのでクラスとして実装しています。

感想

パイプの実装なんてCで何度も書いたことがあるし、spawnインターフェイスがやりたいことを満たしてそうだったのもあって、油断してchild_processだけで対応してしまったけれど、やってみると意外と大変でした。もっとうまい実装方法はあるかなとは思いつつ、個人的には、次に同じ処理が必要ならexecaのようなライブラリを使うと思います。

*1:npmにmemorystreamはあるけどメンテナンスされてなさそうだった