自分用の電子書籍管理サーバー「Bookwall (仮)」を作った

最近、ストレージサーバーが起動しなくなったり(ストレージ自体は無事)、Android買ったりした関係で、自宅サーバーの整理をしていた。

その中で今迄雑にcalibreに突っ込んでNAS共有だけしていた電子書籍を、汎用的に管理できる仕組みに移行しようと色々調査していた。

しかし、既存の何もかもが自分にとって中途半端というか日本人に噛み合わない作りになっていて我慢ならなかったので、Claudeを使って自分で作ることにした。

大抵の場合は、kavitastumpで良いとは思うが、自分には合わなかった。

既存のものが気に入らなかった理由

filenameに規約がある場合が多い

kavitaなどが顕著だが、ファイル名やディレクトリ名を勝手に解釈してメタデータに割り当てる。規約で決まってるので自分に都合の良いファイル名を付けられない。勝手に加工されて意図しないメタデータが入ることの方が多くてむしろ邪魔だった。

Web Readerの挙動

CBZを開いた時の見開き表示のオフセットコントロールが大抵上手くいかない。物によっては右から左に読むマンガスタイルの読み方が出来ない場合すらある。

EPUB readerも横書きしか考慮されていないケースが多い。縦書き + ルビを正しく表示できるものは非常に少ない。

kavita, stumpはどうだったか

kavitaはreaderが比較的良くできていて、自分が調べた限りでは、WebUIで縦書きとルビを正しく表示できるほぼ唯一の実装だった。しかし、CBZの見開き表示を変に自動でコントロールしようとしてオフセットのズレを手動で調整できないし、ファイル名規約に思想が強くメタデータのマッピングがアメコミに依っている。日本人向きではない。

stumpはメタデータ管理に余計なことをしないし、ディレクトリ名をシリーズに割り当てる挙動もある程度カスタムできる。しかし、CBZ readerはkavitaと同じ問題を持っていて、EPUB readerは横書きオンリーだった。縦書きのEPUBを開こうとすると、そもそも表示できないかフォントがおかしくなる。そして、自分がiOSで利用しているOPDS対応の電子書籍リーダーを経由してアクセスするとアプリが落ちる。

こんな感じで、自分にとってはとにかく噛み合わない。そもそも日本人が作ってないので当然ではある。CJK文化圏は大体こういう方面ではスルーされる。

という訳でAIパワーで開発に踏み切った。

この類のシステムは、UIは大分決まり切っていてやりたいことも明確で、ただ面倒なだけだったので実にAI向きな開発ネタだと言える。

Bookwallについて

github.com

システム構成は以下の様になっている。

Directory Role Stack
server/ API + OPDS delivery, SQLite + Active Storage, book scanner, authentication Rails 8.1 / Ruby 4.0 / Falcon / Thruster / SQLite (FTS5) / SolidQueue
client/ SPA mounted under /ui. Book list / detail / search / favorites / settings / Web Reader Vite + React 19 + TypeScript / Tailwind v4 / shadcn/ui / React Router / TanStack Query / Zustand / foliate-js / PDF.js

久しぶりにRailsで新しくなんか作るかーってので、FalctonとThrusterとSolidQueueという新しめの構成を採用。基本的にVPN内部でセルフホストする用途なので認証も組込みの機能を利用してシンプルに。

UIは独立したSPAで、Vite + React構成。UIコンポーネントにはshadcnを利用した。自分はUI構成の面倒臭さに割と耐えられないタイプなので、claudeがゴリゴリ作ってくれて本当に助かる。

Readerの実装はCBZ readerは普通にRails側でzipを読んで画像を返したりキャッシュしたりする仕組みを用意して画像レンダリングするだけ。

EPUBの実装にはfoliate-jsを利用した。これは直近でLinuxで利用できるEPUB Readerを探していた時に見つけたやつのバックエンドで、自分が知る限りでは、JSでまともに縦書きとルビ表示が出来る唯一の代物だった。

PDFはPDF.jsを利用してcanvasにtextマッピングする形。これでテキストの選択やコピーが出来る。

Falconによる並列リクエストサポートとReact SPAのおかげで結構キビキビと動いてくれる。ライブラリスキャンは高速なSSDに保持していない限りはディスクI/Oネックになるので、CPU並列は余り効果が無いからRubyでもそんなに困らない。

基本的な機能

サポートフォーマット

  • CBZ (ComicInfo.xml 対応)
  • EPUB
  • PDF
  • 画像ディレクトリ

スキャン・メタデータ

  • EPUB 拡張メタ: dc:subject をタグ、Calibre の calibre:series / calibre:series_index をシリーズ・巻数として取り込む
  • シリーズ フォールバック: メタデータにシリーズが無い書籍は親ディレクトリ名を採用
  • 表紙画像: フォーマットごとに 1 ページ目を抽出して Active Storageに保存
  • 全文検索: SQLite FTS5 によるタイトル / シリーズ / 著者の AND 検索
  • タクソノミー閲覧: シリーズ・著者・タグ別の一覧 + サムネ表示。ライブラリ詳細はブック / シリーズビューをトグル可能
  • ソート: 新着順・登録日順・タイトル順・シリーズ順・著者順 (昇降)

Web Reader

  • CBZ / PDF / 画像ディレクトリ: ページ画像配信 + クライアント側で表示。見開き / 1 ページ表示、LTR / RTL、4 種類のスケールモード
  • EPUB: foliate-js ベース。目次、フォントサイズ、テーマ (light / dark / sepia)、書字方向 (auto 自動判定 / horizontal / vertical) を per-book で保存
  • 読書進捗: ページ位置 (CBZ) と CFI + fraction (EPUB) を自動保存。表紙の下端に進捗バーをオーバーレイ、ホーム画面の最近読んだ本カルーセル、書籍詳細ページに反映
  • ホバーで現れるスクラバー: 下端から引き出して任意ページに直接ジャンプ。CBZ はホバー位置のサムネを、EPUB は対応する章ラベルをプレビュー表示
  • キーボードナビ + ヒント: 矢印 / Space / Backspace / Esc に加え、CBZ では 2 で見開きトグル、Shift + 矢印 で 1 ページだけ送り。? でショートカット一覧
  • クリック領域とホバーフィードバック: 左右 12% のみクリッカブル (中央の文字選択を妨げない)、ホバーティント付き

配信・連携

  • OPDS / OPDS-PSE: Atom フィードで他のリーダーアプリに配信
  • 認証: Cookie セッション (UI 用) と Bearer トークン (OPDS / Reader 用) を併存
  • モバイル対応: 390px〜の viewport で動作

デモ

大体こんな感じで動く。

開発の流れと所感

と言っても、要件を書き出してclaudeにぶん投げて細かく調整指示出しただけだが。

しかし、やっぱ細かいところはおざなりになるもんで、重複した実装が増えたりReactの使い方が雑で画面がちらついたりとかそういうことは多々あるので、ある程度動いたら怪しいところを指定してリファクタリングさせたりとかはしている。

playwright-cliを使って動作確認させていて、基本シナリオはe2eテストとして再利用可能にすることで、挙動のバグとかはclaude自身がかなり自主的に判断してくれる様になった。playwrightはデモ動画を作るのにも利用しているし、後からsnapshotで挙動が追えるのでやはりとても便利。自分はUIの挙動確認がとにかくダルいので、こういうことを高速でやってくれるのは良いと思う。

Railsのコードについては、まあそこそこって感じ。何も指示しないと結構ファットコントローラーな実装をしてくる。後サービスクラス作ってくるw まあ止める程ではないし、用途的にも妥当だと思うが、ファットコントロールと実装重複は見逃せなかったのでざっくりコード読んでチクチク直させた。

一応rubydexのmcpサーバーとかも入れてあるんだけど、効果あんのかどうかは何も分からんw

ちょっとハマった点

FalconとSQLiteとActiveStorageを同時に使って、画像のthumbに並列でアクセスをかけると、そのタイミングで画像生成とActiveStorageの更新が並列で走りまくりSQLiteがめちゃくちゃ書き込み競合を起こして処理が停止する。

これについてはActiveStorageの動きを細かく制御しないとどうにもならんので、事前にライブラリスキャン時にシーケンシャルにvariantを作成する様にして並列リクエスト時に書き込みが走らない様にした。

まとめ

まだちょこちょこ改善するポイントはあるが、自分が使う分には特に困らない電子書籍サーバーが完成した。 一旦外から参照してみたが、場所を選ばずに一元管理されたサーバーからCBZ, EPUB, PDFが滞りなく読めることを確認した。

こういう既存のものが存在するが、自分の利用には中途半端で噛み合わないというものがあった時に、今迄は面倒臭さでかなりスイッチ入れないとやる気にならなかったので、とりあえずざっくり要件だけ書いて大体どうにかなる様になったのはライフチェンジングだなーと思う。viteとかshadcnとか普段全然触ってないんで、チマチマAPI調べるの面倒臭い。

なんにせよ、久しぶりにRailsで自分用のシステムが作れて良かった。

補足: OPDSとEPUBのCFIについて

Open Publication Distribution Systemの略で、書籍などの出版物の配信に関するオープンな規格がある。

また、CBZなどのページストリーミングに対する拡張規格である、OPDS-PSEが存在する。OPDS-PSEは基本的にCBZ用で、EPUBの場合は、CFIというコンテンツのフラグメントを表現するインターフェースがあるため、EPUBを読み込んだ後にfoliate-jsに保存していたCFIを読み込ませることで読書途中のページを復帰できるので、OPDS-PSEには頼らない。PDFの場合はPDF.jsがRangeリクエストに対応していて、段階的にファイルをリクエストすることで読み込みデータ量をコントロールしている。

この規格自体は、書籍のレンタルや販売にも対応できる様なメタデータを持てる様になっているが、自分用のサーバーなのでカタログとページリクエストにのみ利用している。

主要な電子書籍管理サーバーはこの規格を実装しており、OPDSのフィードが配信できれば、複数の電子書籍リーダーでそのアプリのUIを利用して書籍の一覧やページストリーミングが出来る様になり潰しが効く。とは言え、それなりに実装はあるがそこまで豊富ではない。この辺りも国産のアプリは非常に弱くて、自分が知る限りで日本語の書籍を含めてちゃんと動くのはiOSのComicShareぐらい。

カタログをどの様に配信するかはサーバー側の実装の自由なので、この辺の柔軟さもKavitaやStumpで満足いかなかった要因の一つだ。自作していればいくらでも自分の好みで軸を追加できる。

補足2: ComicInfo.xml

CBZファイルにメタデータを埋め込むための規約。

CBZファイルのルートにXMLでメタデータを記述することで、ライブラリスキャン時にメタデータを解釈することができる。書式を決めておけば潰しが効く。ちなみにcalibreにはcalibreのメタデータをComicInfo.xmlとして埋め込んでくれるプラグインがある。 スキーマはこんな感じ。結構色々入れられる様になってる。

これをどの様にマッピングするかはサーバー側の自由なので、この取り扱いが噛み合うかどうかも電子書籍管理サーバーの選択に影響すると思う。

今後

とりあえずWebブラウザ経由で利用する分には、PC・スマホ・タブレットそれぞれで困らないことは確認できた。

iOSに関してはComicShareで大体何とかなるので、後はAndroidのアプリを作るかなーと考えている。用途を限定してOPDSに特化したリーダーアプリならすぐに出来そうな気がする。

AIにロゴとか作らせようかどうしようか……。でも別にそういうオシャレ感要らんしなー……。

メインのスマホをAndroidにしてみた

メイン端末はずっとiPhoneだったんですが、Macを全く使わなくなって長いしLinux使ってるとiTunesが使えないので、iPhoneを使い続けるのもなーと思ってPixel 10aに乗り換えることにした。iPhone 13から変えるのを渋ってたので、iPhone止めようと思ってから2,3年は経っている。

調べてみたら、iPhone 17は大体12万ぐらいする一方で、Pixel 10aは廉価版なので大体8万弱で買えるので値段的にもありがたかった。まあストレージ量とか差はあるんだけど、別にスマホでゲームやらんしこれで充分だろうと。

移行作業も落ち着いてしばらく経ったので、良かった点、良くなかった点の感想を書いていこうと思う。

良かった点

音楽視聴関連

Androidに変えてみて、一番良くなったのは音楽視聴関連の諸々。これは完全にAndroidに乗り換えて改善した。 昨今流石にワイヤレスイヤホンじゃないと不便なので、Bluetoothを使うことになると思うのだが、iPhoneはLDACに対応する気が全く無いのでずっとAACで接続することになる。 AndroidにしてやっとLDAC接続が出来る様になったのだが、明らかに音質変わったので、本当iPhoneのBluetooth接続は微妙だったなーと思う。

自分は、subsonic互換のサーバーを立てていてVPN接続でストリームで聞ける様にしてあるので、subsonicクライアントアプリをいくつか触ってみたのだが、AndroidにはSymfoniumというsubsonicクライアントがあり、これが良く出来ている。 iOSに存在しているクライアントアプリは、まあ悪くはなかったんだけど、なんかちょこちょこ不満あるなって感じのものが多かったが、Androidには良い感じのものが見つかって良かった。

後、YouTubeプレミアムだと、YouTube Musicをアラームにセットできる様になるので、目覚しバリエーションが簡単に増やせるのも良い。

Widgetや通知

iOSも最近それっぽいの配置できる様になってますが、この辺はやっぱAndroidに一日の長があるというかパッと見で便利に情報確認できるのは良い。 例えばSuicaのチャージ額をホーム画面に出しておけるのは良い。DatadogのアラームとかClickupのReminderを出しておけるのも、自分の環境では便利。

キーボードの自由度

SSH接続した時に使えるプログラマー向けキーボードみたいなものの選択肢が多い。今のところunexpected keyboardというのを使っている。多少癖はあるがSSH接続時には充分に便利。

良くなかった点

スクロール機能

トップまでスクロールバックする機能がアプリ依存。これはとても良くない。アプリに依っては最悪不可能。何故なんだこれ。Appleが特許持ってんのかな。

クレカ決済

Visaタッチが使えるカードが物凄く限定される。不可能なのではなくイシュアーが対応してないせいでもあるんだが、こんなことのためにカード会社限定されたくない訳で諦めてQuicpay生活に戻った訳だが、時々困るんだよなー……。

iOSはなんだかんだで優遇されてるというか、やっぱこっちがファーストサポートなんだなーと。

後、ウィジェットの動きもちょっと微妙というかSuicaを改札のためのデフォルトカードにしておくと、ショートカット起動した時のデフォルトもSuicaになってしまう。iOSではそこが分離されていてウィジェット起動した時に何も操作しなくて良かったのにAndroidでは一手間必要になる。これは良くない。

決済周りは日常的に良く使う割に体験がそれなりに悪くなっていて、残念だった。

電子書籍リーダー

iOSにComicShareという個人的最強のリーダーアプリがあったが、それに比肩する電子書籍リーダーがAndroidには存在しない。 ComicShareは、OPDSやNAS共有に対応していて、I/Fに不満が全然無く、ソートやグルーピングが柔軟でページ数syncも簡単で、CBZもPDFもepubも読めて縦書きのepubもルビもちゃんと表示される。

Androidで一番頑張ってるのはCDisplayEXというアプリだと思うけど、これはOPDSに直接対応してなくてKavitaとかKomgaみたいな特定サーバー対応だったりepubに対応してなかったりする。申し訳ないが全然追い付いてない。

海外産の電子書籍リーダーは基本的に日本人の漫画と小説文化を全然理解してないので、大体が微妙である。いくつも試したが大抵はすぐにアンインストールすることにした。そもそも日本語のepubがまともに開けないことすらしばしばある。

現状、CDisplayEXとKOReaderの組み合わせがギリギリ実用というイメージだが、KOReaderは縦書きで読めない。まあスマホなんで別に我慢できなくはないが。

LLMのおかげで実装に関する面倒臭さは一気に解消されつつあるので、もう自作しようかなーとか考えている。まあその前に適当に配置してsamba共有してたやつのメタデータを整理するところからやらないとって感じだが。

電子書籍管理に関しては思うことが一杯出てきたので、いずれ別途ブログにまとめようかと思う。

総合的

まあ、体験が悪い点はそれなりにあったんだけど、発売したてのスマホが8万円以下で手に入って、電池の持ちも性能も特に不満が無いので総合的には満足している。

これでついにLightningケーブルが必要な生活からおさらばだ。長かったわー……。

RubyKaigi 2026でLT登壇してきました

早いもので、もうRubyKaigi 2026から1ヶ月が経とうとしている。 ちょっとブログ書くのサボってたらこんなに経ってると思うと恐ろしい……。 HPが0になった状態でGWにすぐ突入したので、しばらく虚脱状態でずっとゲームやってました……。

という訳で俺のRubyKaigi 2026を完了させるためにも、そろそろブログを書いていこうと思います。

久々のLT登壇

今回、LTだけど本当に久しぶりにRubyKaigiの壇上に立つことができました。確か7年ぶりですね。 ちなみに、RubyKaigiのLTの登壇回数は自分が最多ぐらいのラインらしい。(小ネタなら豊富という……)

ここしばらくは、仕事ではJavaを書いてデータパイプラインの構築やアーキテクチャのことばっかり考えていて、Rubyに全然向き合ってなかったので、RubyKaigiは楽しむものの自分から何かを提供できるものが全然思い付かなくて、CFPにも全く応募してませんでした。(趣味で色々やる体力も大分減ってきてたりという事情もあり……。)

しかし、今回はtagomorisさん(以下モリスさん)がRuby::Boxという熱い機能を作り上げてキーノートに望むということで、何かしらやりたいと強く思ってました。

というのも、自分は2018年の仙台で行われたRubyKaigiで、モリスさんと一緒にコンビを組んでRubyの黒魔術でRubyのSyntaxをハックするというネタでRubyKaigiに登壇したことがありまして、その頃からモリスさんとはRubyで悪さする仲間だと思っていたので、そのモリスさんがキーノートをやるからには面白そうにRuby::Boxが動いてるところを見せるのは、是非自分がやりたいなーと思ってたのでした。

という訳で、Ruby::Boxについて色々事前に触ったりしていたのですが、自分はRailsの中身にもある程度詳しいので本当にRubyがぶっ壊れるレベル以外の問題なら対処できるんじゃないかと思って、RailsのモジュラーモノリスをRuby::Boxで分離するコンセプトをデモできるんじゃないかと思って挑戦してみました。 その過程で、Ruby::BoxのBox間の参照の自由さと継承チェーンに組込むことで発生する挙動が中々興味深いことに気付いたので、これをLTのテーマとしてまとめることにしました。 内容は以下のスライドにまとまっています。

speakerdeck.com

結果として結構ギリギリで、登壇時点ではマジで特定のバージョンでしか動作しないという感じではありましたが、無事デモまで漕ぎ着けることができました。

今回LTの順番がトップバッターだったのですが、これはLTらしいLTをやって場を温めてくれ、という自分に対するメッセージだと受け取って、スタイルも自分が感じる昔ながらのLTを意識して作りました。

実際、RubyKaigiのLTって本編トークより聞いてる人の総数は多いので緊張するし、緊張でトピック一個すっ飛ばしたり時間感覚ズレると取り返しが付かないこともままあるので、不安もあったんですが、なんとかそれっぽいLTが出来た感触はあったので、トップバッターの仕事は果たせたかなと思います。

自分が最初だと後のトークも聞き易くて良かったですね。

という訳で、久しぶりにRubyKaigiという大好きなカンファレンスでLTらしいLTが出来たので、今回のRubyKaigiにはとても満足してます。

聞いてたトークで印象に残ったもの

どれも面白いトークだったのは間違いないんですが、すぐにパッと思い出せるぐらい記憶に残ってるのは、次の2つです。

ぺんさんのDigitの話は、Digitの話自体というよりプログラマーとしての姿勢が本当にカッコイイなと思って印象に残ってます。「出来ない」ものを「出来る」様にするって、単純だけどめちゃくちゃプログラマーとして大事なことで、それをスマートなやり方で解決する方法を考えるのってワクワクするじゃないですかって楽しそうに語る姿が本当にカッコ良いと思いました。車輪の再発明上等というのもすごく分かる。自分で考えて辿り着くことが大事なんだと思います。

AaronさんのFFIの話は、解決方法がめちゃくちゃCoolでそのアイデア面白いなーって印象に残ってます。関数ポインタのエントリと実態のコードの間に通常の処理ではjmpですっ飛ばすバイトコードを埋め込んで型情報のヒントをJITに与えるというやり方で、既存のインターフェースを変えずに必要になった追加情報を与えていて、魔術的なんだけど自分にとってはかなり印象に残るテクニックでした。

函館も最高だった

今回、会期中はほとんど天気の崩れもなく、例年では咲いてないはずの桜が満開で、本当にRubyKaigiというイベントは何かしら持ってるイベントだなーと思いました。

五稜郭の満開の桜が見れるとは全然思ってなかった。

いやー、Asakusaでやってる花見とは大違いですよ、本当にw

後、なんと言ってもWhite Seedですね。本当に良いビアバーだった。RubyKaigiコラボビールを2種類作ってくれて、どっちも美味しかったー。a_matsudaさんが飲み疲れしないことを重視して細かくオーダーしてたらしく、それをマスター自身もしっかり受け止めてくれた感じで、実際毎日飲める仕上がりになってました。帰る日までに大分飲んだなー。

本当にお世話になったし、良いビアバーだった。函館にまた来ることがあれば是非寄りたい。毎日顔出してビール話してたので、急速に常連みたいになってたのが面白かったw

そして、寿司や海鮮もしっかり食べれました。最終的に何が美味かったというと、自分としては鰊がめちゃくちゃ美味いなと思いました。東京に存在している鰊とは味もサイズも完全に別物で、そういう差分みたいなものを一番感じたのが鰊だった。後、3日目に入った地元っぽい居酒屋でお通しって言われて、すげーでかい刺し盛りが出てきて、全員で「は?」ってなってたのは面白かった。その後も結構飲み喰いしたのに一人5000円ぐらいで、物価バグってんじゃないのかと思いました。

今回も、御当地のものはそれなりに堪能できたかなーと思います。RubyKaigiが行く機会を作ってくれて、いつもありがとうございます。

来年は宮崎とのこと。ひでぢビールと宮崎地鶏ってのが自分のイメージだけど、魚も美味そうだなー。

これにて、俺のRubyKaigi 2026は終幕になります。大きな問題が無ければ、もちろん来年も参加するつもりなので、Rubyistの皆さんはまた来年、宮崎でお会いしましょう!

Yubikey終章 YubikeyでLinuxのディスク暗号化を開錠する

最近、8年ぶりぐらいに社用のノートPCを新調しました。 主に緊急時のオンコールとかカンファレンス時のTwitter用とかなので、そんなに大したデータを入れるつもりではないが、持ち歩くことは持ち歩くのでちゃんとディスクの暗号化ぐらいはしておかねばならない。

で、ちょうどいい所に最近買ったYubikeyがあるので、こいつで開錠可能にしておけばキーファイルも必要無いし、打つのがダルいほど長いパスフレーズも必要無くなる、という訳で調べて設定してみました。

Linuxでディスク暗号化をやるならブロックデバイスを暗号化する標準的な仕組みであるdm-crypt + LUKSに則ってやるのが良さそうです。

この辺りを参考にしました。

保存データ暗号化 - ArchWiki

dm-crypt/システム全体の暗号化 - ArchWiki

YubiKey - ArchWiki

今回はルートパーティションを普通に一個作ってシンプルにそれをLUKSで暗号化する形にします。この辺は最近のLinuxだと暗号化にチェック付けるだけでインストーラーが勝手にやってくれます。

LUKSの開錠にはいくつかのパターンがあるのですが、arch系のディストリビューションではmkinitcpioというツールによって作成されたinitramfsに入っているカーネルモジュールとHookスクリプトによって実施されます。

initramfsは、カーネルが起動して本物のルートファイルシステムをマウントする時に、事前に必要なものを準備しておく仮のファイルシステムです。

Yubikeyで暗号化を開錠するにはいくつかやり方がありますが、今ならsystemdの仕組みの中でFIDO2を使うのが一番楽そうだったのでその方法を選択。

systemd-cryptenroll --fido2-device=auto /dev/nvme0n1p5

こんな感じでインストール時に暗号化されたルートパーティションでfido2デバイスを利用する様に指定できます。

バイスの登録が出来たら、起動時にもfido2デバイスを利用する様にカーネルの起動オプションを設定します。ブートローダーによって設定のやり方は違いますが、systemd-bootだと以下の様になります。

title Linux Cachyos
options root=UUID=<root filesystem uuid> rw rootflags=subvol=/@ rd.luks.name=<crypted block device uuid>=cryptroot rd.luks.options=<crypted block device uuid>=fido2-device=auto nowatchdog splash
linux /vmlinuz-linux-cachyos
initrd /initramfs-linux-cachyos.img

重要なのはrd.luks.optionsにfido2-device=autoを追加すること。後はcrypttabにも同じ設定を追加しておきます。(起動時に既に開錠してるので、もしかしたらこっちは要らんかも)

cryptroot UUID=<crypted block device uuid>     fido2-device=auto

mkinitcpioのデフォルト設定でsd-encryptフックが有効になるはずなので、多分これだけでいいと思いますが環境によってはsd-encryptフックを明示的に組み込んでinitramfsを再生成しないと駄目かもしれません。

この状態で起動すると以下の様な案内が出るので、YubikeyのFIDO2のPINを入力してキーにタッチすればデバイスの復号化が出来て起動するはずです。

(既に埃が大分ww)

ここまで出来たら、パスフレーズは削除するかいざって時のために非常に長いものに変更するか別途バックアップ用のキーファイルを準備すれば良さそうです。

入力の手間やキーファイルのために別途USBメモリを持ち歩いたりしなくても、もっとコンパクトなデバイスで復号化が可能になったので、セキュリティと利便性のバランスが良い配分になったかなーと思います。

Yubikey続編 GPG鍵もYubikeyに入れたがその時にハマったgnupg内蔵CCIDの非互換問題

昔、Gitのコミット署名用に作ったGPG鍵があったんだけど、大分古くなってたしRSAだしってことで作り直すついでにYubikeyに入れることにした。

管理方針は以下の記事の内容に納得がいったので、そのまま採用。主鍵と暗号化鍵をYubikeyに入れてsignature用の副鍵を必要に応じて作る形に。 fuwa.dev

作業自体は、gpgコマンドで鍵作ってバックアップ取ったらkeytocardやるだけなので難しいことは無かったのだが、一つハマるポイントがあった。

ググれば出てくるんだけど、gpg-agentが素の状態だとYubikeyと通信できない問題が発生する。 gnupgはscdaemonというプロセスを通じてスマートカードとやり取りするらしいのだが、それに組込まれてるCCIDライブラリがYubikeyと互換性が無いらしい。(2.3辺りからそうなったとのこと)

なので、~/.gnupg/scdaemon.confを作成しdisable-ccidと記述して組込みのccidを無効化し、pcscdを入れてそっちのccidライブラリを使う様に切り替える必要があった。

公式サイトの案内もある。https://support.yubico.com/s/article/Resolving-GPGs-CCID-conflicts

地味に分かりにくい問題なので、備忘録を残しておく。

諸々設定できたので、GitHubの古い鍵を廃止したし、ついでにKeybaseのアカウントを取って公開鍵をちゃんと検証に使える様にuploadした。まあ、あんまり使うことないとは思うけど。

keybase.io

Yubikeyを買ったのでLinuxのログインや1passwordのロック解除が出来るようにしてみた

最近、認証情報を整理してファイルにベタ書きしているデータを撲滅し1passwordに寄せたら、やたら1passwordの認証が要求される様になってしまったのでYubikeyをポチった。

予定通りLinuxでも無事設定できたのでまとめておく。

ちなみに、今回買ったのはこれ。

www.yubico.com

国内代理店は完売してるので直接yubicoのサイトから買った。

Type-CにしたのはノートPCとかタブレットとかでも利用するなら将来的にこっちの方がいいかなと。自宅で利用する場合はAの方がいいんだけどアダプタ噛ませば済むので。

Yubikeyの管理

yubikey-managerをインストールする。

ykman infoファームウェアバージョンなどを確認できて、ykman configで不要な機能を無効化したりできる。

重要なのはPINの設定でYubikey NFCは指紋認識とかはできないのでPINを設定しておかないと鍵を持っていかれた時にヤバイことになる。FIDOで利用するPINの設定は以下のコマンドで行う。

ykman fido access change-pin --new-pin $PINCODE

初期時点ではPINが設定されていないのでこれでPINが登録される。ちなみにPINの変更回数は上限があるらしい。

Linuxの認証

Linuxの認証はPAMというモジュール群によって行われている。 Yubikeyの販売元であるYubicoがOSSとして開発しているpam-u2fを利用すればFIDO2認証がPAMで利用できる様になる。

pam-u2fをインストールして以下のコマンドで接続しているYubikeyに対応した鍵情報ファイルを生成してくれる。 デフォルトのファイル配置場所は~/.config/Yubico/u2f_keysになるのでそこにファイルを出力する。

pamu2fcfg -o pam://$HOSTNAME -i pam://$HOSTNAME > ~/.config/Yubico/u2f_keys

後はPAMの設定ファイルを弄るだけで良い。しかしPAMの設定を間違えて設定を壊すと一切ログインできなくなったりするので、設定が正しいかはしっかり検証した方がいい。

自分は以下の様に設定した。

ログイン

/etc/pam.d/system-local-login

auth     sufficient      pam_u2f.so origin=pam://<hostname> appid=pam://<hostname> cue openasuser pinverification=1
auth        include     system-login
account     include     system-login
password    include     system-login
session     include     system-login

sufficientでpam_u2fを登録することで、Yubikeyの認証が終わった時点でログインを可能にしている。2要素認証をしたい場合はrequriedにすれば良い。 cueを有効にすることでYubikeyへのタッチを要求するプロンプトを出す。pinverificationを有効にすることで認証時にPINを必須にする。 hostnameは各自の環境に読み替えて欲しい。

この設定で起動時のログインもhyprlockなどのスクリーンロックも同じ設定を参照するのでYubikeyだけで認証可能になる。パスワード入力の所にPINを入力してからデバイスに触れれば良い。

sudo

/etc/pam.d/sudo

auth sufficient      pam_u2f.so origin=pam://<hostname> appid=pam://<hostname> cue openasuser
auth    substack        system-auth
account substack        system-auth
session substack        system-auth

こっちは既にログイン後なのでPIN認証もなしでYubikeyが接続されているなら触れるだけで認証可能にしている。

1password (polkit)

1passwordのロック解除はシステム認証サービスを使ってロック解除を有効にすると、polkitを経由してロック解除を行う様になる。

これも、一旦マスターパスワードでロック解除した後しか使えないのでPIN認証は不要にした。

/etc/pam.d/polkit-1

auth     sufficient      pam_u2f.so origin=pam://<hostname> appid=pam://<hostname> cue openasuser
auth         include            system-auth
account     include         system-auth
password     include            system-auth
session     include         system-auth

ちなみに、polkit向けのpam設定のデフォルトは/usr/lib/pam.d/polkit-1にあるので、そこからコピーしてから変更した。

1password-cliで必要になる際にYubikeyに触れるだけで済む様になったので大分楽になった。

SSHの鍵をFIDO対応させることもできるのだが、一旦後回しでいいかなと今は変更していない。

という訳で、LinuxでもYubikeyは簡単に使えるし便利なので安心してポチって良い。

実際のところFIDO以外の認証は余り利用しない気はするが、Yubikey自体は結構色々できるので気になったらArchWikiを参照してみると良い。 GPG鍵の管理をYubikeyに任せるのは良さそうだと思ったが、今のところコミット署名にしか使ってないので別にそんなに気を遣わなくてもいいかなーという感じ。

Omarchyの裏側にあるuwsmについての解説

(自分はOmarchyに全く興味ないので、タイトルに入れてるのはこう書いといた方が見てくれる人居るかなーという雑な理由によるもので、Omarchyの話はほぼありません。)

最近のモダンなWaylandデスクトップの裏側にはuwsm - Universal Wayland Session Managerというものが居ることが多いんですが、これが何なのかとても分かりにくいし、日本語で説明されてる文書がほとんど無かったのでブログにまとめてみることにしました。 多分、需要はほぼ無いと思いますが、自分の理解を一回整理しておくメモでもあります。

Waylandデスクトップ

モダンなLinuxデスクトップは大抵Waylandというディスプレイサーバーを利用する様になっていて、これは今迄Linuxで使われていたディスプレイサーバーのXを置き換えるものです。 パフォーマンスの向上、GPU活用、セキュリティ上の懸念の解消、メンテナンス性を高める、などの目的で置き換えが進んでいます。

ただ、通信プロトコルの策定はそんなにスムーズにいってない様で、結構unstableという名前がついたプロトコルに依存して現状動いているといった感じです。

Waylandのデスクトップは今迄のXの仕組みとは関係が無いところで動くので、uwsmを利用しない場合は、startxみたいなコマンドは使わず直接ウインドウマネージャーのコマンドを実行して起動します。

GnomeとかPlasmaみたいなフルスタックのデスクトップ環境を利用している場合は、そこに任せれば良いんですが、世の中にはミニマルなタイル方ウインドウマネージャーを利用したい派閥がそれなりに居ます。

自分はHyprlandユーザーだし、OmarchyもHyprlandを利用する様になってる様です。i3の設定とかなり互換性があるswayも結構人気があります。

Wayland起動時の問題とそれを解決するuwsm

startxとそれに付随するX関係のシェルスクリプト読み込みが発生しないので、普通にコマンドを使って直接起動すると、今迄Xで動作していた自動起動とかGUI向けの環境変数の設定(XMODIFIERS=@im=fcitxみたいなIMの利用設定とか)を読み込んでくれません。

素朴な解決策としては、ラッパーのシェルスクリプトを書いてそこで環境変数を設定してからコマンドを起動すれば良いんですが、それだと管理しづらいし単純に面倒臭い。特に自動起動はダルい。

という訳で、uwsmの出番になります。

uwsmはWaylandデスクトップの起動コマンドをAdhocなsystemdのunitにラップして起動することによって、GUIのための環境変数を登録し自動起動して欲しいものを全てsystemdで管理可能にします。

uwsmの設定方法はUniversal Wayland Session Manager - ArchWikiを参考にしてみてください。 自分はReGreetというgreetdGUIフロントエンドから起動する様に設定してみました。

uwsmの動作

uwsmはざっくり以下のことを実施する。

  1. ウインドウマネージャーのコマンドを実行するためのsystemdのunitファイルを生成する
  2. unitファイルの依存関係を利用して、prehook posthookを仕込めるsystemdのtargetに対する依存関係を設定する
    • systemdの依存関係によってwayland-session@%i.targetがアクティブになり、それを経由してgraphical-session.targetがアクティブになる様に依存関係が構成される
  3. XDG Autostartの管理対象のディレクトリにあるdesktopエントリを起動するunitファイルを動的に生成する
    • このunitファイルはgraphical-session.targetに依存しており、graphical-session.targetがアクティブになった後に起動される
  4. uwsm用のemv設定ファイルに書かれた環境変数を設定した上でウインドウマネージャーのsystemd unitが起動し、環境変数dbusAPIを経由してsystemdに登録される

細かい順番は違うかもしれない。

こうすることで、systemdのunitの依存関係にgraphical-session.targetを設定しておけば、uwsmによってウインドウマネージャーが起動した時に必要なコマンドをsystemdが自動的に起動してくれる様になる。

これでXDG Autostartに登録される旧来のアプリも自動起動が効くし、新しいwayland向けのアプリであればgraphical-session.targetに依存したsystemdのunitファイルをセットで提供してくれるものも多くあるので、systemctl --user enable自動起動を有効にできる。

例えば、Wayland環境でよく利用されているタスクバーのwaybarで提供されているsystemdのunitファイルは以下の様になり、利用しているウインドウマネージャーの設定方法に関わらずsystemctl --user enable waybar.service自動起動が有効になる。

# /usr/lib/systemd/user/waybar.service
[Unit]
Description=Highly customizable Wayland bar for Sway and Wlroots based compositors
Documentation=https://github.com/Alexays/Waybar/wiki/
PartOf=graphical-session.target
After=graphical-session.target
Requisite=graphical-session.target

[Service]
ExecStart=/usr/bin/waybar
ExecReload=kill -SIGUSR2 $MAINPID
Restart=on-failure

[Install]
WantedBy=graphical-session.target

ウインドウマネージャーを終了する時もsystemdが依存関係を見て自動的にプロセスを落としてくれる様になる。

uwsm利用下でのアプリ起動

もう一つの重要な機能として、手動で何らかのアプリケーションを起動する時にそれをsystemdの管理下に置く機能がある。

uwsm app -- <command or desktop entry>としてコマンドを実行すると、そのコマンドをsystemdで管理しているslice下に紐付けてくれる。

systemdはデフォルトでapp.slice, background.slice, session.sliceという3つのsliceを提供していて、これはcgroupのリソース管理と紐付けられている。デフォルトではbackground.sliceだけCPUWeightが30に設定されており、CPUリソースを食い合った時の優先度が下がる様になっている。

uwsmはこれを拡張してapp-graphical.slice, background-graphical.slice, session-graphical.sliceというsliceを提供している。sliceは-区切りで階層構造が表現できるらしい。

これらのsliceはこんな感じの中身になっている。

# /usr/lib/systemd/user/app-graphical.slice
[Unit]
Description=User Graphical Application Slice
Documentation=man:uwsm(1) man:systemd.special(7)
PartOf=graphical-session.target
After=graphical-session.target
Conflicts=wayland-session-shutdown.target
Before=wayland-session-shutdown.target

このsliceに紐付いているプロセスはgraphical-session.targetの一部で、wayland-session-shutdownのtargetがアクティブになったら終了しますよって感じの内容になる。ウインドウマネージャー起動後にランチャーなどで起動したアプリをsliceに紐付けることで、cgroupの設定を上書きしたり、wayland sessionのshutdown時に自動的にプロセスをちゃんと終了する、といった動作が可能になる。

uwsmはこんな感じで、コマンドをラップしてsystemdのunitやscopeに変換し、targetに対する依存関係を利用することでデスクトップ環境起動時の自動起動や終了時のプロセス停止を安全に行う方法を提供してくれる。

最初、何故こういうものが必要になるのか良く分かっていなかったのですが、ちゃんと設定すると環境変数自動起動の管理が大分楽になることが分かったし、ウインドウマネージャーが落ちた時にゴミプロセスを残さずに済むことが分かりました。(まあ、後者の状況になったら大抵はrebootかけますが……)