モニタの入力ソースを切り替える macOS のアプリを書いた

Dell U4025QW で入力ソースを Mac のキーボードで切り替えたい (KVM) - daily dayflower の続きの話。

現時点で BetterDisplay で "次の入力ソース" を実現することは困難だが、 DDC を直接さわることで、現在の VCP 0x60 (Input Source) の状態を取得し、それとは異なる Input Source を指定することで実現できそうである。時間ができたらそのようなツールをつくってみようと思う。

と書いたが、年末ということで時間もあるので CLI コマンドをつくってみた。

github.com

入力ソースを指定して切り替えるだけであれば、前回記事でも紹介した m1ddcAppleSiliconDDC (こちらは自分でバイナリをビルドする必要がある) を利用すればいい。

ただ、個人的に、入力ソースを順次切り替える機能がほしかった (これによりすべての PC で同じ設定をすればよくなる) ので、AppleSiliconDDC を利用して自分で書いた (実際にコードを書いたのは Claude Code だが)。

Homebrew でインストールすることができる。

brew install dayflower/tap/dispsel

Thunderbolt と DisiplayPort の間で順次切り替えたい場合は

dispsel switch next dp,thunderbolt

のように実行すればよい。

その他インストール方法や使い方についてくわしくは上記の GitHub ページを参照のこと。

キーボードで切り替えたかったのでは?

もともとの希望としてはキーボードで入力ソースを切り替えたかったはずなのに、ただの CLI コマンドではないかと思う人もいると思う。

最近はこういう "キーボードショートカットでなにかをしたい" 場合は、ショートカットキーを自分でハンドリングするのではなく、機能自体を実装した (CLI) コマンドを作成し、 Raycast から実行するようにするようにしている。

なので dispsel には Raycast 用のサンプルスクリプトも添付している。

github.com

注意

DDC/CI を経由して直接モニターを操作しているため、最悪の場合、モニターの故障の原因となりうる。自己責任で利用されたい。

Dell U4025QW で入力ソースを Mac のキーボードで切り替えたい (KVM)

要約: BetterDisplay を使えばできる

2025-12-30 追記: 切り替えする CLI をつくった: モニタの入力ソースを切り替える macOS のアプリを書いた - daily dayflower

導入

モニターとして Dell U4025QW を使っている。

入力として Thunderbolt, DisplayPort, HDMI port があり、また別途 USB downstream port もある。 USB downstream とはモニターにつないだ USB 機器の制御を PC にわたすものであり、ようするに PC からみるとUSB ハブとして振る舞う。

Thunderbolt ポートにつなぐと、ディスプレイとして表示されるのはもちろんのこと、 PC への USB PD 給電、 USB downstream としても動作する。つまりケーブル1本を Mac 等につなぐだけで、 PC への給電からモニターに接続したキーボードやトラックパッドの接続もしてくれるとても便利なモニターである (いまどきのちょっといいモニターだとだいたい同じことはできるが)。

いっぽう、別の PC から DisplayPort や HDMI につなぎ、さきほど言及した USB downstream port もつなぐこともできる (この場合はケーブルが2本、さらに PC 向けに別途充電ケーブルが必要となる)。

この状況では、現在画面に出力される PC 側に、モニターに接続した USB 機器が接続される。つまり、モニター側で画面出力を切り替えると、モニターに接続したキーボード等で操作できる対象の PC も切り替わることになる。この挙動を Dell は USB KVM と称している。

わざわざ “USB” KVM と書いたのは、ほかに Network 経由でキーボードやポインティングデバイスの入力を転送する Network KVM という機能もあるからである (ただし、それぞれの PC に Dell Display and Peripheral Manager — DDPM のインストールが必要)。本稿では Network KVM については取り扱わない。

また、2台の PC をつないだときに両方の画面を同時に出力することもできる。 PIP (Picture-in-Picture) と PBP (Picture-by-Picture) の双方をサポートしており、 USB KVM として入力を切り替えることもできる。この機能についても本稿では取り扱わない。

わたしはこの Dell U4025QW に MBP と MBA を接続している。

  • MBP - Thunderbolt
  • MBA - DisplayPort, USB downstream, (+ USB PD)
  • Display - USB Keyboard, USB Trackpad

キーボードで画面出力を切り替えたい

USB KVM で入力を切り替えるためには、入力ソースを切り替える必要がある。このためには、

  1. モニターまで手を伸ばす
  2. モニター背後にあるスティックをクリックする
  3. 入力信号を選択する (直前に入力信号の切り替えをしているのであればそのままでよい)
  4. クリックする
  5. 入力を選ぶ (”自動” を選ぶと、次の入力ソースに変わる)

このように複雑な手順をふむ必要がある。

また、あまり多用するとこのスティックが壊れるんじゃないかというおそれもある。

ところで、昔ながらの KVM は、物理ボタンによって接続先を切り替えることができるが、キーボードのアクションによって切り替えることができるものもある。

Dell の USB KVM でも、キーボードにより入力ソースを切り替えることはできないのだろうか?

Dell Display and Peripheral Manager (DDPM)

実はさきほどちらっと言及した Dell Display and Peripheral Manager (以前は Display 専用の Dell Display Manager だった) を使うと、キーボードなどから入力を切り替えることができる。

インストールによりショートカットキーによって切り替えができるようになったが、接続しているすべての PC でインストールし起動しておく必要がある。

てっきりモニター本体にショートカットキーが登録され、モニターがキーボード入力を解釈して自動的に切り替えるもの (つまり DDPM の常駐は不要) とおもっていたが、どうやらそうではないらしい。単純に DDPM がショートカットキーをうけとり、入力ソースを切り替えているようだ。

DDPM は Rosetta 2 を要求するし、もっさりしているしで、正直利用したくない。ほかの手段で入力ソースの切り替えができないか考える。

DDC/CI と Input Source

モニタと PC の間で情報をやりとりする仕様として DDC というものがあり、その上でモニターの設定を変更するためのインタフェースとして CI (Command Interface) が定義されている。

ディスプレイの輝度やコントラストを調整することができるが、具体的なコマンドの仕様を VCP (Virtual Control Plane) と呼ぶ。

VCP の中には入力ソースの切り替えも定義されていて、コマンドとしては 0x60 が対応する。

これを利用すれば DDPM に頼らずともモニターの入力を切り替えることができそうだ。

BetterDisplay

ところで macOS 用に BetterDisplay というアプリがあり、ツールバーから解像度を変更できたりする。とても高機能なツールで、解像度の変更だけでなく、仮想モニタをつくることすらできる。

実は無印 M2 以下の Mac で U4025QW をフルスペックで使うには EDID をオーバーライドする必要があり (気になる方はググってください)、もともとそのために BetterDisplay を利用していた。この BetterDisplay を使うと、キーボードショートカットで入力ソースを切り替えることができる。

BetterDisplay の設定のキーボードにおいて “カスタムキーボードショートカット” があり、その “DDC ディスプレイ入力ソース” で指定の入力への切り替えに対してショートカットを定義できる。“デフォルトのディスプレイ” となっているところ “DELL U4025QW” を選択し、入力として “USB-C / TB 1” や “DisplayPort 1” を選択。 キーを記録すれば完了である。

自分の場合、 MBP を Thunderbolt、 MBA を DisplayPort につないでいるため、

  • MBP 側で “DisplayPort 1” への切り替えにショートカットキーを登録
  • MBA 側で “USB-C / TB 1” への切り替えにショートカットキー (同じキー) を登録

することで、同じキーバインドを入力するたびに MBP と MBA で切り替えることができるようになった。

コマンドラインで切り替える

場合によってはショートカットキーではなく、 Raycast などから切り替えたいこともあると思う。

BetterDisplay は CLI インタフェースもあり、それを通じて入力を切り替えることも可能だ。

/Application/BetterDisplay.app/Contents/MacOS/BetterDisplay operation set -productNameLike=U4025QW -feature=ddc -vcp=inputSelect -value=15

value のところは U4025QW の場合、

  • Thunderbolt: 25
  • DisplayPort: 15
  • HDMI: 17

となっているようだ。 U4025QW の場合、と書いたが、この入力ソースの種別は、だいたいのモニターにおいて共通なようである。 (ちなみに Thunderbolt 2 は 27, DisplayPort 2 は 16, HDMI 2 は 18)

その他コマンドラインオプションの詳細は https://github.com/waydabber/BetterDisplay/wiki/Integration-features,-CLI を参照のこと。

フルパス指定ではなくパスの通ったバイナリを実行したい場合、 betterdisplaycli を使うこともできる。 homebrew でインストール可能ということだが、残念ながら手元の環境ではうまくインストールできなかった。このため Releases からビルド済バイナリを取得した。

betterdisplaycli の場合、引数として “operation” は不要であり、

betterdisplay set -productNameLike=U4025QW -feature=ddc -vcp=inputSelect -value=15

となる。

BetterDisplay がない場合

さきに整理したとおり、要件 (仕様) としては

  • VCP として 0x60 が Input Source の read/write
  • value としては
    • Thunderbolt: 25
    • DisplayPort: 15
    • HDMI: 17

であり、 DDC/CI で操作することができるツールがあれば、上記と同じことが可能である。

m1ddc (これは BetterDisplay の作者によるもの) や ddcctl を利用すればよい。

m1ddc の場合、

m1ddc display 1 set input 15

とすれば、 DisplayPort に切り替わる。

ddcctl は手元ではうまく動かなかった。

次の入力ソースに切り替えたい (未達)

いったん希望することは実現できた。しかし、それぞれの PC で接続するポートが固定になっているし、個別の設定が必要になる。

Dell の DDPM では、ショートカットキーにより、 “次の入力ソース” に切り替えることができる。これは DDC/CI VCP により実現可能だろうか?

BetterDisplay では DDC の詳細情報を取得することもできる。これによると VCP 0x60 (InputSource の選択) で指定可能な値は 25, 15, 17 だけである (個別の値についてはすでに解説済み)。

実は隠しコマンド的に次の入力ソースに切り替えができる値があるのかと思い、0, 254, 255 あたりでためしてみたが、残念ながら切り替えることはできなかった。ほかの値で適合するものがあるのかもしれないし、 Input Source とは違う独自 VCP コマンドにあるのかもしれない。あるいはまったくそのようなものはないのかもしれない。

現時点で BetterDisplay で "次の入力ソース" を実現することは困難だが、 DDC を直接さわることで、現在の VCP 0x60 (Input Source) の状態を取得し、それとは異なる Input Source を指定することで実現できそうである。時間ができたらそのようなツールをつくってみようと思う。

2025-12-30 追記: つくってみた

dayflower.hatenablog.com

まとめ

BetterDisplay を利用することで、キーボードショートカットからモニタの入力ソースを切り替えることができた。本稿では U4025QW を利用しているが、USB downstream も追随して切り替え可能なモニタであれば、同様に KVM として動かすことができる。仮に USB downstream に対応していなかったとしても、モニタの入力切り替え自体は動作する。

また、 CLI による操作についても説明した。これを利用すれば Raycast などから切り替えることが可能であるし、また、一定時間ごとに入力ソースを切り替えるデモモードなども実現可能だろう。

追記: この構成に最適な USB ハブをみつけた

5in1 のやつ。

モニタの Thunderbolt に接続する Primary な PC に対しては、ディスプレイ接続、キーボード接続 (USB downstream)、給電 (USB-PD) を USB-C (Thunderbolt 対応) ケーブル1本でまかなうことができる。

一方、 Secondary な PC は DisplayPort で接続することになるが、 Dell U4025QW としては、DisplayPort 側端子は USB downstream に対応していないし、 USB-PD 給電もしてくれない。

つまり、 Secondary PC、わたしの場合でいうと MBA に対して、

  • DisplayPort (DP Alt mode on USB-C)
  • Keyboard/Trackpad USB
  • USB-PD

の3つの接続をする必要があるわけだ。

しかし PC をモニタに接続するにあたって3本の線を抜き差しすることになるし、そもそも MBA は 2 ports しかない。

ということで、それに適した USB(-C) Hub はないかなあと長年探してきたけどなかなか最適なものはなかった。しかしついに最近みつけることができた。それが上記の商品。

3本の USB-C (DisplayPort on USB-C / Input / USB-PD) を Hub に接続し Hub からでているケーブルを MBA に刺すだけ。

これまでは Anker PowerExpand+ 7-in-1 (終売) で USB-PD と Keyboard/Trackpad USB をまかない、 DisplayPort は別途つなぐという2本体制だったのが、1本の抜き差しで済むようになったのがうれしい。

(Anker のはカードリーダーや USB-A、 Ethernet 端子などもついていて充実しているといえば充実しているが、 DELL U4025QW にそういった入出力を集約する使い方だと余計なものになってしまう)

欲をいえば USB-C 端子が (DP Alt mode passthrough 含めて) 2個で十分なので port 数が少なくてもっと小さい (短い) ものがあればいいのだが、 DP Alt mode passthrough をサポートしていて USB-PD も対応しているものは本当にすくないのでぜいたくは言えない。

参考文献

github.com

本文中で解説した、モニタを制御するクローズドソースのアプリ。 CLI としての動作も可能。

github.com

DDC/CI を通じてモニタの輝度やボリュームを操作することができるオープンソースのアプリ。残念ながら入力ソースの切り替え機能はなさそう。現在は BetterDisplay の作者がメンテしている。

github.com

Swift 製の DDC 操作ライブラリ。サンプルとして CLI も付属している。 BetterDisplay の作者によるもので、 MonitorControl でも利用されている。

github.com

これも BetterDisplay の作者によるもので、 Apple Silicon mac で DDC/CI を通じてディスプレイを操作するツール。おそらく BetterDisplay の源流になっていると思われる。

github.com

Mac 用の DDC/CI 操作ツール。 2022年で更新が止まっているし、手元ではうまく動作しなかった。

www.ddcutil.com

Linux 用のきわめて著名な DDC/CI 操作ツール。

www.ddcutil.com

ddcutil のドキュメントに記載されている Mac 用ツールへのリンク集。

github.com

Dell U4323QE における PBP (Picture-by-Picture) に絞った DDC/CI 解説 (解析)。

ここに VCP 0xE7 に 0xff00 を書き込むことで next input にできるという記述があった。試してみたところ、あくまで PBP or PIP mode (つまり両 PC の画面同時出力) での USB 入力の切り替えにしか用いることができず、入力ソースの切り替えはできなかった。

github.com

DDC/CI により入力ソースやスピーカーをコントロールする StreamDeck のためのツール。

github.com

AutoHotkey (Windows) で入力ソースを制御するツール。

Vaultwarden のデータを定期バックアップしてクラウドストレージにアップロードする

Bitwarden API 互換のサーバサイド実装として Vaultwarden が有名だが、そのデータをバックアップするツールを Golang で書いた。

github.com

Vaultwarden の 3rd party バックアップツールは公式 Wiki の Backup に関するページ の Examples にも複数挙げられているし、ここに掲載されている以外にも Google 等で "Vaultwarden backup" と検索するといくつか見つかる。

それらを利用してもよかったのだが、個人的には以下の要件を満たしたかったので自力で書いた。

(おそらくそれらのツールをうまいことセットアップして設定すると要件を満たすことも可能だったとは思うが)

  • バックアップファイル自体を暗号化できること
    • そもそも Vaultwarden (Bitwarden) 的に Vault が流出したとしても、パスフレーズがわからないと復号できないしくみにはなっているので、そこまで必須な要件でもないが、念のために……
  • クラウドストレージにバックアップファイルをアップロードできること
  • Docker コンテナとして簡単に動かせること
    • つまり K8s クラスタで CronJob 等により簡単に定期実行できること
      • さらに Helm Chart で簡単にインストールできること
  • できれば (依存のない) シングルバイナリであること
    • 単純にイメージつくるのが楽だから
    • このため Golang を採用した。 Rust という手もなくはなかったが、(後述するように) 内部で利用している RcloneGolang で書かれているため結果的に最善手となったと思う。

ちなみにバックストアストレージとして SQLite を利用している場合しか対応していない。

以下、利用方法 (については上記 GitHub レポジトリを参照してください) ではなく、おもに実装にまつわる話を書く。

バックアップのための方針

どのファイル (ディレクトリ) をバックアップすればいいかについては、公式 Wiki の Backup your vault に詳細に書いてある。

SQLite をバックアップするためには、単純にファイルをコピーするのではなくスナップショットをとる必要がある。

Vaultwarden 1.32.1 以降の場合、そもそも vaultwarden コマンドに (DB の) バックアップ機能が搭載されているのだが、 Docker image としてシングルバイナリにしたかったのもあり、 Golangsqlite 実装を組み込むことにした。

利用したモジュールは github.com/ncruces/go-sqlite3。なんとなく CGO を利用したくなかったというのがある。(深い理由はない)

これで VACUUM INTO を利用してバックアップをファイルとして保存している。

バックアップファイルの暗号化

OpenSSL で復号できる形式で暗号化しており、 実装としては github.com/Luzifer/go-openssl/v4 を利用している。

これは OpenSSL の実装モジュールというわけではなく、下部では標準の crypto/aes を利用して OpenSSL と互換性のある出力をすることができるモジュールである。

クラウドストレージへのアップロード

各種クラウドストレージに対応している Rclone を利用している。

さいわいにも Golang で書かれており、またライブラリとして利用することもできる。このため、 rclone コマンドを Docker image に同梱しているわけではなく、内部に組み込むことができた。(このためシングルバイナリにできた)

ライブラリとして利用するためのドキュメントが充実しているわけではないので、 rclone CLI の実装を読みながら雰囲気で実装することになってしまったが……

どのクラウドストレージにアップロードするか

利用方法ではなく実装について書くといったわりにここだけ運用について書くことになるが……

Rclone はもともと各種クラウドストレージ向けの OAuth の認証コード (Client ID / Key) がデフォルトとして入っている。 しかし Rclone を利用するすべての人で共有されることになり、 rate limit をオーバーすることも多い。 そのような場合は自前で App を用意して Client ID と Key を発行すればよい。(Microsoft OneDrive での例)

最初 Microsoft OneDrive を利用していたのだが、 refresh token がそのうち expire してしまい、定期実行するうえでの障害となってしまっていた。

このため Google Drive を利用することにした。 しかし当初こちらも refresh token が expire してしまっていた。 原因は単純に Google で発行した App が開発中モードになっていたことだった。 いまはちゃんと公開モードに変更した結果、継続してバックアップできている。

おわりに

自分がほしかった要件を満たすために自分のために書いたツールだが、満足いくものができた。 流行りの vive coding ではなく *1、 (GitHub Copilot は使っているものの) ほぼ自力で書いている。ただ README についてはほぼ完全に Gemini に書かせた。

これで毎日自動的にバックアップしているが、実はまだバックアップファイルから復旧する素振りをしていない。

*1:単純にこれを書き始めたころまだ流行っていなかっただけともいえる

Bitwarden スマホアプリから Vaultwarden につなげなくなったら

Bitwarden の API spec が変更になったらしく、しばらくは新旧いずれの spec でもアクセス可能だったが、 今のスマホクライアントでは新 spec しか対応していない (らしい)。

Vaultwarden を Version 1.31.0 以上にアップグレードする必要がある。

github.com

ref. Change API and structs to camelCase by dani-garcia · Pull Request #4386 · dani-garcia/vaultwarden · GitHub

Traefik から Envoy Gateway に乗り換えた

以前書いたように、おうち k8s クラスタではクラスタ外からのアクセスのために Traefik を導入していた。

dayflower.hatenablog.com

ところが、 (最初はうまくいっていたものの) あるときから Helm でインストールした MySQLIngressRouteTCP で外部からアクセスできなくなってしまった。

(telnet で接続してみたところ通常 server から接続直後に応答があるべきところ client からなんらかのパケットを送らないとセッションが開始しない)

いろいろ調べてみたがどうにもうまくいかず、せっかくなので別のソフトウェアに乗り換えることにした。

Istio にしようかとも思ったが、サービスメッシュのような複雑なことはするつもりはなくコンパクトなものがいいなと思い、 同じ Envoy ベースの Envoy Gateway にすることにした。

Traefik Proxy Envoy Gateway
ベース Traefik Proxy Envoy
Ingress Controller ×
Gateway API
独自 CRD
TLS 証明書管理 ×

星取表でみるかぎり完全に Traefik に負けてしまうのだが、裏を返すと限定した機能にフォーカスしているといえる。

Ingress に対応していないのがちょっとつらいところだが、 Gateway API のほうが実現できる機能も多いため、 Envoy Gateway でいくことにする。 Envoy に興味もあったし。

インストール

Helm でインストールした。

Traefik とちがい、 values はデフォルトからいじっていない。

これは、 Traefik にくらべて TLS 証明書管理機能がないこと、また、対象とするポート等の設定は Resource として定義することから、本体自体のカスタマイズは特に必要なかったことなどが理由と思う。

Argo CD での Helm インストール

CRD が大きすぎて、デフォルトだと Argo CD で管理できない (Too long: must have at most 262144 bytes のように怒られる)。

適当に調べて Replace mode で配布するようにしてみたが、それでもうまくいかなかったので、結局手で helm install することにした。

いま改めて調べると Replace ではなく Server Side Apply を利用するほうがよいらしい (ref. Fixing Argo CD "Too long must have at most 262144 bytes" error)。

時間ができたらふたたび Argo CD による配布に挑戦してみようと思う。

(2025-03-14 追記: spec.syncPolicy.syncOptionsServerSideApply=true を付与することで、無事 Argo CD で管理できるようになった)

セットアップ

cert-manager による TLS 証明書管理

Traefik はいい感じにTLS の証明書を管理してくれる (ACME 対応の認証局であれば自動発行も可能) が、 Envoy Gateway にはそのような機能はない。

なので、定番である cert-manager を利用することにする。

自分は k8s 基盤として MicroK8s を利用しているので cert-manager addon を enable してインストールした。

(MicroK8s での説明 だと、あたかも Ingress 環境があることが必須のように読めてしまうが、 ACME DNS01 Challenge をするぶんには Ingress は必要ではない)

Let's Encrypt で *.wildcard.example.com の証明書を発行する resource は以下のようになる。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  namespace: default
  name: wildcard.example.com
spec:
  issuerRef:
    kind: ClusterIssuer
    name: luadns-issuer
  secretName: wildcard.example.com-tls
  commonName: wildcard.example.com
  dnsNames:
    - wildcard.example.com
    - "*.wildcard.example.com"

metadata.namespec.secretName はなんでもかまわない。

spec.issueRef はこのあいだの記事で定義した ClusterIssuer を参照している。

dayflower.hatenablog.com

spec.secretName で指定した Secret に証明書が格納される。 これを後述する Gateway から参照することになる。

GatewayClass の定義

Gateway をハンドリングする controller を指定する GatewayClass を定義する。

Ingress における IngressClass と同じようなものと思えば問題ない。

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller

Envoy Gateway (Controller) の設定をカスタマイズするためには、以下のような記述になる。 (オフィシャルの example が参考になる)

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  parametersRef:
    group: gateway.envoyproxy.io
    kind: EnvoyProxy
    namespace: envoy-gateway-config
    name: config
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  namespace: envoy-gateway-config
  name: config
spec:
  ...

spec に設定可能なパラメータは オフィシャルドキュメントの EnvoyProxySpec を参照のこと。

とはいえ、通常の場合わざわざカスタマイズする必要もなく、 GatewayClass だけ定義すればよいと思う。

Gateway の定義

Traefik の場合、どの port を開けるかといった設定はすべて起動時オプションで指定する必要があった (Helm でインストールする場合は values.yaml で指定)。

Gateway API ではそういった port 設定もすべて CRD になっている。 具体的には Gateway resource を利用する。

---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  namespace: default
  name: envoy-gateway
spec:
  gatewayClassName: envoy-gateway
  addresses:
    - type: IPAddress
      value: 192.168.0.100
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            namespace: default
            name: wildcard.example.com-tls
      allowedRoutes:
        kinds:
          - kind: HTTPRoute
        namespaces:
          from: All
  • spec.gatewayClassName に上記で設定した GatewayClass の name を指定する
    • Ingress や PersistentVolume に似ている
  • spec.listeners[].tls.certificateRefs に上記で設定した cert-manager による Certificate resource により生成される Secret resource を指定している
  • spec.listeners[].allowedRoutes.kinds に、当該 Gateway に利用可能な route resource を指定している (後述)
  • spec.addresses は、External LoadBalancer (metallb 等) が適切にセットアップされていれば、とくに設定する必要はない
    • 筆者の環境では (MicroK8s に) metallb は導入しておらず、 single node の物理 I/F にアサインされたアドレスをそのまま利用しているので、 IP アドレスを明示的に指定している

ルーティングの設定

詳細な説明は不要かと思うが、実際の Service に対応する HTTPRoute resource の定義はたとえば以下のようになる。 (ちなみに HTTPS であっても HTTPRoute を利用する。HTTPSRoute はない)

---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: httproute
spec:
  parentRefs:
    - name: envoy-gateway
      namespace: default
      sectionName: https
  hostnames:
    - 'nantoka.wildcard.example.com'
  rules:
    - backendRefs:
        - name: service-name
          port: 80
      matches:
        - path:
            type: PathPrefix
            value: /
  • spec.parentRefs に上記で設定した Gateway resource を指定する
    • spec.parentRefs[].sectionNameGateway resource の listener で設定した name を指定する
  • spec.rules[].backendRefsトラフィックをルーティングする Service を指定する

感想

以下に Traefik から乗り換えてよかったところ、残念だったところをあげる。

よかったところ

  • 標準にのっとっている安心感 (まだ core ではないが)
    • 実際には Traefik も標準 Ingress resource / Gateway API resource を提供しているが
  • 設定まわりがきちんと CRD として定義されており、管理がしやすい

残念なところ

  • 設定が CRD なので、裏をかえすと、利用したい機能にたいして、定義するべきリソースが増える
  • Ingress がない
    • これはそもそも Envoy Gateway のポリシーだからあたりまえではある。だが、
    • 他の Helm chart 等で Ingress 設定を指定できるケースがあり、その場合でも別途 HTTPRoute resource を自力で定義しないといけないのがめんどくさい
  • Traefik とちがい Web の status dashboard がない
    • じゃあ実際に使うのかというと使わないのだが、あればあったで登録されている route を web から一覧でみれて便利

cert-manager で let's encrypt (ACME) の証明書を LuaDNS を利用しつつ発行する

Let's Encrypt の証明書を発行するフローには ACME HTTP-01 と DNS-01 の2つがある。

HTTP-01 の場合、事前に 80 番ポートで対象となるドメインにファイルを設置する必要があり、またワイルドカード証明書に対応できない (と、思う)。

DNS-01 の場合は対象ドメインDNS の TXT レコードに特定の内容を設定する必要があるが、発行にあたって事前に HTTP 通信を行う必要がないし、ワイルドカード証明書も発行できる。

cert-managerk8s 上で TLS 証明書の取得や更新をするものであり、 ACME DNS-01 に対応している (たぶん HTTP-01 にも対応している)。

DNS の設定をおこなう必要があるが、各種 DNS プロバイダ にオフィシャルに (ビルトインで) 対応している、が、リストをみてもらえるとわかるとおり、有名どころはあるがその数は少ない。

さまざまな DNS プロバイダへの対応コードを cert-manager に組み込んでいくとメンテナンスやクオリティコントロールの面で課題があるので、 cert-managerWebhook Issuer という形で、外部から拡張可能にしているらしい。

野良を含め Webhook Issuer は https://github.com/topics/cert-manager-webhook にリストアップされている。 が、残念ながらわたしが利用している LuaDNS は存在しなかった。

未対応の DNS プロバイダに対応するためには https://github.com/cert-manager/webhook-example を参考に実装していけばよい。

以前 Traefik on k8s で let's encrypt のワイルドカード TLS 証明書を自動発行する - daily dayflower で書いたように、 Traefik はもともと LuaDNS に対応している。 どのようなコードで対応しているのかな、と思ったが、実際には GitHub - go-acme/lego: Let's Encrypt/ACME client and library written in Go ライブラリを利用しているようだ。

lego の LuaDNS むけ実装cert-manager の webhook example 実装 を照らし合わせてみてみたが、 (DNS-01 チャレンジを実装するという意味で) 似たような構造になっている。

これならそこまで難しくなさそうだぞと思い、 cert-manager webhookLuaDNS 向け実装を書き始めた……

が……

気づいてしまった。

LuaDNS 専用に書くより https://github.com/go-acme/lego のサポートしている DNS プロバイダを汎用的にサポートすることができるんじゃないか?

そもそもそういう実装がすでにあるんじゃないか?

と、思い、探してみました。すでにありました。

github.com

これで自力で開発する必要はなくなった (!) ので、ふつうにこれを使わせてもらうことにする。

(実際には ArgoCD での利用にちょっと問題があったので (あくまで Helm chart 部分だけだけど) fork してそれを使っている: https://github.com/yxwuxuanl/cert-manager-lego-webhook)

といっても、使い方は難しくはなく、 README にあるとおりにやればよい。

まず webhook を Helm でインストールする。

自分はいまは k8s cluster として MicroK8s を使っており、 cert-manager を addon として容易に追加できる。 この場合、 Helm install するときに指定する value としては certManager.namespacecert-manager、および certManager.serviceAccountNamecert-manager となる。

次に Issuer リソース を登録する (以下の例では ClusterIssuer を使っている)。

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: luadns-issuer
spec:
  acme:
    privateKeySecretRef:
      name: luadns-issuer
    server: https://acme-v02.api.letsencrypt.org/directory
    email: EMAIL-ADDRESS
    solvers:
      - dns01:
          webhook:
            groupName: lego.dns-solver
            solverName: lego-solver
            config:
              provider: luadns
              envFrom:
                secret:
                  namespace: LUADNS-SECRET-NAMESPACE
                  name: LUADNS-SECRET

metadata.name (および spec.acme.privateKeySecretRef.name) は自由につけれる。

それ以外の部分は (おそらく) lego の LuaDNS プロバイダを利用するなら上記のような設定になると思う。

あとは一般的な cert-manager での証明書リソースの登録をすればよい。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  namespace: default
  name: example.com
spec:
  issuerRef:
    kind: ClusterIssuer
    name: luadns-issuer
  secretName: example.com-tls
  commonName: example.com
  dnsNames:
    - example.com
    - "*.example.com"

spec.issuerRef に上記で登録した Issuer を指定する。

SOPS と age で暗号化されたファイルを Spring Boot から読み込む

SOPSage が何をするものなのか、はここでは説明しない。

また、公式ドキュメントを読み込んで理解したというより、手探りでやってみたことから推測しているだけなので、もし間違いがあればご指摘ください。

この文書、および サンプルプロジェクト ともに暗号化文書としては YAML のみ取り扱っているが、 JSON においてもほぼ同様に対応できると思われる。

github.com

SOPS で暗号化されたファイルの構造

サンプルファイルとして vault.yaml を挙げる。

暗号文の構造

暗号化された文の部分の構造はわりと自明で、

password: ENC[AES256_GCM,data:PSyc......Ofw=,tag:oMXi5h7kOSOx3oGQVqm67A==,type:str]

のようになっており、以下の情報が詰め込まれている。

  • AES256_GCM - 暗号化アルゴリズムは AES (鍵長 256 bit) の GCM モードである
  • data は暗号文
  • iv は IV (初期化ベクトル)
  • tagMAC (Message Authentication Code)
    • GCM モードに独特
  • type は元の値の型
    • ここでは str (文字列) であることを示しており、ほかに bool, int, float などがある

暗号文の AAD

GCM モードの場合、暗号化の際 AAD (追加認証データ) を指定することができ、整合性チェックに利用できる。

SOPS で暗号化された値には AAD が付与されており、復号時に AAD をきちんと指定しないと不整合エラーとなってしまう。

SOPS における暗号文の AAD はちょっと独特である。

基本的には tree の path を : でつないで、末尾に : を付与した形式となる。 たとえば上記の password フィールドの場合、 "password:" である。

ただし、配列の場合は配列の添え字 (数値) については無視する。

具体的には、

root: secret
first-level:
  second-level:
    third-level: secret
    arrays:
      - secret
      - secret
    objects:
      - key: secret
         value: secret
      - key: secret
         value: secret

のような場合、 AAD はそれぞれ

  • root の secret → root:
  • first-level.second-level.third-level の secret → first-level:second-level:third-level:
  • first-level.second-level.arrays[0] の secret → first-level:second-level:arrays:
  • first-level.second-level.arrays[1] の secret → first-level:second-level:arrays:
  • first-level.second-level.objects[0].key の secret → first-level:second-level:objects:key:
  • first-level.second-level.objects[1].value の secret → first-level:second-level:objects:value:

のようになる。

暗号化鍵

暗号文は age のキーで直接暗号化されている、わけではない。

当該ファイルで共通に利用される DEK (Data Encryption Key) で暗号化されており、おのおのの (age) key を KEK (Key Encryption Key) として暗号化した DEK が SOPS ファイルに記録されている。

このように DEK と KEK を分離することで、異なる (age) key をもつ人々の間で暗号文のやりとりができるし、また age に限らず多様な鍵プロバイダの利用者ともやりとりができることになる。

さらに age key を変更することなく DEK のキーローテーションも可能となっている。

age に限った話をすると、age公開鍵暗号方式を利用している。 KEK としては公開鍵のほうを利用するので、共同利用者として登録してもらうためには公開鍵だけわたせばよい (公開鍵だけでは DEK の復号は不可能)。

age の場合、 sops.ageKEK (の公開鍵) と、それによって暗号化された DEK が記録されている。

sops:
    age:
        - recipient: age10v42a8geamzvv6c0p6aakw2s5u24vhwl3uhged05fepxgfgewytq7eh98m
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBISWs4R1dZelJLeTNxMkRp
            WlhTa20wM3h0NTV1LzRzaWZNangxMW0vN1Q4CllMYWZaYjBOTjlLNytFN2dMdTBH
            V25lZURHK3JSMlpNNzRHZy94ajl6dVUKLS0tIGg1RXRLcG5ETVNEWi9zYTMzQWVF
            a0ZFd2VjZ2VrYlhrTTBZc1NxZWVEWXcKZGeB3UxdzFSmgRk68DiZ+i3Miw3sA5Vt
            Z9olYHBY3tZ98o1h/yzD1vMQUAK8bEPH3n7xAnEv2EnHT9WpRUsWgw==
            -----END AGE ENCRYPTED FILE-----

この場合は一件しか登録されていないが、 sops.age.[].recipientKEK (age の公開鍵) であり、 sops.age.[].enc が、それによって暗号化された DEK である。

(上記の構造からわかるとおり、先にあげたように、複数の recipients を指定することが可能である)

したがって、対応する age秘密鍵を保持している場合、 age コマンドを使うことで (生の) DEK を導出することができる。

$ age --decrypt -i keys.txt -o dek.bin dek.enc

暗号化対象プロパティ

sops:
    version: 3.9.4
    unencrypted_suffix: _unencrypted

sops.version はいうまでもなく SOPS 暗号ファイルのバージョンを示す。

sops.unencrypted_suffix は、元文書で暗号化の対象外となる "キー" のうち、暗号化の対象としないキーを示す。 sops コマンド実行時になにも指定しなかった場合、 _unencryptedunencrypted_suffix となる。

具体的には

foo: encrypted
bar:
  key: encrypted
  value_unencrypted: plain
baz_unencrypted:
  key: plain
  value_unencrypted: plain  

のように、 *_unencrypted となっているノードの子孫についても暗号化の対象外となる。

unencrypted_suffix だけではなく、以下の4つの指定子が存在する。

  • unencrypted_suffix
  • encrypted_suffix
  • unencrypted_regex
  • encrypted_regex

詳細については 公式ドキュメントの Encrypting only parts of a file 項 を参照してほしい。

全体の MAC

sops:
    lastmodified: "2025-02-16T06:24:31Z"
    mac: ENC[AES256_GCM,data:fAu8......yw==,type:str]

sops.lastmodified はファイルの最終更新日時を示す。

sops.mac はファイル全体の MAC (Message Authentication Code) を示す。 これにより、ファイルの一部の書き換え・削除や暗号文のおきかえ・コピー (たとえば foo.bar の暗号文を hoge.fuga にコピーする) といった改ざんを検出できる。

具体的には、各ノードの値を連結して DEK で暗号化した結果……だったと思うが、コードを読んだのがだいぶ前なので忘れてしまいました。

sops コマンドによる暗号化の際に --mac-only-encrypted オプションを付与すると、 (暗号化対象外も含めた) 全ノードではなく、暗号化対象ノードのみとなる、らしい。

くわしくは 公式ドキュメントの Message Authentication Code 項 を参照のこと。

ちなみに sops.mac の AAD (追加認証データ) は sops:mac: ではなく、上記の sops.lastmodified の値となる。

Spring Boot で SOPS (+age) 暗号文書を読み込む

JavaScript / TypeScript の場合 SOPS 暗号文書を読み込むライブラリがあるようだが (sops-age npm module, GitHub repository) JVM 言語用ライブラリは (自力で読み込むものが) ぱっと見当たらなかったので、上記の解析結果をもとに自力で書いてみた。

github.com

復号方法

age による復号 (および暗号化) については jagged というすばらしいライブラリがあるので、それを利用した。

SOPS (というより AES256-GCM) の復号については、 Java 標準の javax.crypto を利用している。

javax.crypto の場合、 cipher.update() に暗号化文と tag の双方をあわせて入れる必要がある。

application properties としての読み込み

Spring アプリケーションから properties として読み込むためには、まず java.org.springframework.core.env.PropertySource (@PropertySource とは別物) を定義する。 (SopsVaultPropertySource.kt)

この独自 PropertySource で SOPS 暗号文書を復号して(SopsVault.kt) properties を返すことにする。 (どうでもよいが、この springframework.core.env.PropertySource が型パラメータを必要とすることが納得いかない)

アプリケーション起動時に自動的に読み込まれるようにするためには、さらに、

手順を踏むことがプラガブルにする上でまっとうなやりかただと思う (くわしくは SpringBoot 標準の RandomValuePropertySource や、その EnvironmentPostProcessor 指定 が参考になる)。

ただこの方法だと PropertySource を configurable にするのがいささか難しいので (起動時に設定したいので、 configuration として application.properties を原則利用できない)、 (Spring Boot ではなく) Spring FrameworkApplicationContextInitializer<ConfigurableApplicationContext> を利用することにした。

github.com

具体的には ApplicationContextInitializerinitialize()environmentpropertySources (の先頭) に追加している。

これにより、プログラマティカルに設定をしやすくなったと思う。

ただ、 Spring Boot の application main で以下のように initializer を指定する必要がある。

    SpringApplicationBuilder(ExampleApplication::class.java)
        .initializers(SopsVaultApplicationContextInitializer())
        .run(*args)

任意の SOPS 暗号文書を application properties の元となる PropertySource として読み込むこともできるはずだが、 IntelliJ (Ultimate) の properties の補完等がうまくいかない可能性が高い。

このため、サンプルプロジェクトでは vault.* として SOPS 暗号文書を読み込み、実際の properties としてはその値を Property Placeholder に読み込むような利用方法としている。

app:
  password: ${vault.password}

(https://github.com/dayflower/sops-vault-example/blob/main/src/main/resources/application.yml#L3-L4)

placeholder は設定値の一部でも問題ないので、たとえばクレデンシャルが URI 等の一部に入っているケースでも活用できると思う。

おわりに

がんばって Spring Boot で直接読み込むサンプルを書いてみたが、コンテナ時代ではここまでやらなくても

  • initContainers で sops コマンドを利用して復号する
  • クレデンシャルのみ Secret にいれる
  • application properties 全体を Secret にいれる

などの方法で十分だと思う。