← シリーズに戻る

実測: CI から SSH で配る権限の削り方

Part 1 / 6 Raspberry Pi デプロイ5方式を7軸比較

配る側から機械に入って、ファイルを置いて、プロセスに読み直させる。手でやっている作業をそのまま自動化した形なので、5方式のうちもっとも直感的で、そしてもっとも機械側に何も置かないやり方になります。

7軸で見ると、強いのは常駐コストと観測性です。機械の上で動き続けるものは1つも増えず(置くのは公開鍵と sudoers の数行)、何が起きたかは CI のログに全部残ります。弱いのは到達性とロールバックで、内側に居る機械には入れず、失敗したときに戻す手続きは全部自分で書くことになります。

この方式の検証状態: 実機で動かしています(Raspberry Pi 1台、GitHub Actions から Tailscale 経由で SSH)。ただしロールバック経路は意図的に壊して発火させるところまでやっていません。以下、動かして確かめた部分と、書いただけの部分を区別して書きます。権限をどう削ったかまで含めた実装当時の設計ログはTailscaleでPiにCIデプロイ:権限3層にあります。

経路

push 型の経路。git push で GitHub Actions の使い捨て runner が起動し、ephemeral な端末として VPN に一時参加してから SSH で機械に入り、sudoers に列挙した install と reload だけを実行する。機械に置いてあるのは authorized_keys と sudoers の数行だけで、常駐するものは増えない。経路を切りたくなったらアクセス制御リストの1行を消せば全部の矢印が切れる

素直です。問題は最初と最後に集中していて、どうやって入るかとどこまでの権限で置くかの2つに絞られます。

到達性: 入れないところにどう入るか

機械が LAN や VPN の内側に居ると、外の CI からは届きません。入り方は3つあります。

入り方利点欠点
ポートを開ける増やすものが何もありません22番が公開され、総当たり攻撃の的になります
常駐の踏み台を置く入口が1台に集まり、通った跡もそこで追えます踏み台の OS 更新と鍵の管理が、動かし続ける仕事として増えます
CI 側を一時的に内側に入れる常駐が1つも増えず、job が終われば経路も消えますVPN の資格情報が新しい鍵になります

3つ目が最近やりやすくなっています。tailscale/github-action は OAuth の資格情報から使い捨ての認証鍵を作り、runner を ephemeral な端末として VPN に参加させます。ephemeral は「その場限りの」という意味で、普通に登録した端末が電源を切っても管理画面の一覧に残り続けるのに対して、こちらはログアウトした時点で自動的に消える端末を指します。

CI の runner のように毎回別の使い捨てマシンが出入りする用途のための仕組みで、後片付けのコードを自分で書かずに済みます。job が途中で失敗して終わっても端末は消えます。

内側に居る機械への入り方3つの比較。ポートを開ける方式は増えるものが何もない代わりに、22番が外に見えて公開され、総当たり攻撃の的になる。常駐の踏み台を置く方式は入口が1台に集まり通った跡をそこで追えるが、動かし続ける踏み台が1台増え、OS 更新と鍵の管理が動かし続ける仕事として増える。CI を一時的に内側へ入れる方式は、runner が ephemeral な端末として job の間だけ VPN の内側に参加するので常駐は1つも増えず、ログアウトで端末ごと消え、job が途中で失敗しても消える。残る弱点は VPN の資格情報が新しい鍵になること

到達できる範囲は、VPN 側のアクセス制御リストで閉じます。「この tag を持つ端末から、この機械の 22番だけ」と書いておけば他の機械にも他のポートにも行けず、止めたくなったらこのルールを消すだけで経路そのものが消えるので、緊急停止のスイッチが1箇所に集まります。

常駐コスト: ほぼゼロ

この方式の強みがここです。機械側で動き続けるプロセスは1つも増えません。置くのは SSH の公開鍵と、後で書く sudoers の数行だけです。踏み台も要らず、CI の実行環境も GitHub 側の使い捨てマシンをそのまま使えます。

対照的に、機械に CI の実行環境を常駐させる構成だと、そのプロセスが落ちたことに気づく仕組みがまた別に要るので、監視の設定を配りたいだけなのに、監視しなければならないものが1つ増えます。5方式の中で、これを完全に避けられるのは push 型だけです。

権限の粒度: ここが山場

配る側が機械に入るということは、入った先で何ができるかを決めないといけないということです。設定ファイルを /etc の下に置いてサービスを reload するには root が要ります。

最初に詰まるのは sudo です。実行のたびにパスワードを聞きます。人が打っているなら答えればいいのですが、CI にはキーボードの前に座っている人がいません。聞かれた時点で、job は入力待ちのまま時間切れになります。

そこで、この配布用ユーザーのときだけ聞かずに通す、という設定を機械側に書きます。/etc/sudoers の NOPASSWD: です。

deploy ALL=(root) NOPASSWD: ALL

左から、deploy というユーザーが、どのホスト上でも、root として、パスワードを聞かれずに、ALL(あらゆるコマンド)を実行できる、と読みます。この1行で CI は止まらずに通るようになるので、最初はこれを書きたくなります。

そしてこれを書いた時点で、CI を踏まれたら機械の root を取られます。CI は外部からの入力、つまり誰かが push したコードで動くものなので、ここを緩めておくと、リポジトリへの書き込み権限がそのまま機械の root 権限と同じ意味になります。

狭めるのは最後の ALL です。sudoers では、コマンド名だけでなく引数まで含めて許可を書けます。

Cmnd_Alias DEPLOY = \
    /usr/bin/install -o root -g root -m 0644 /tmp/staging-*/*.yml /etc/myapp/*.yml, \
    /usr/bin/systemctl reload myapp, \
    /usr/bin/myapp-validate /etc/myapp/config.yml

deploy ALL=(root) NOPASSWD: DEPLOY

書くときに効くのが4点あります。

転送元と転送先をペアで固定します。 install をコマンド名だけで許すと任意のファイルを任意の場所に root 権限で置けてしまい、それは実質 ALL です。引数まで書けば、そこは塞がります。

ただしワイルドカードで書いた範囲は、読んだときに思うより広いです。 sudoers は引数を1本に連結した文字列として照合するので、* が引数と引数のあいだの空白も、パスの / も跨ぎます。上の /tmp/staging-*/*.yml は、途中に別のオプションと別のパスを挿し込んだ呼び方にも一致します。可変部分をワイルドカードで表現せずに済むならそのほうが硬く、可変部分が要るなら引数を取らないラッパースクリプトに寄せます。一致範囲を手元で確かめた結果と、絞り方を4段階に整理したものはsudoers引数制限の4段階:*は空白を跨ぐに分けて書きました。

rm を許すなら対象もパスで縛ります。 古いバックアップの掃除で必要になりますが、引数を固定しないと意味がありません。

書いた後に、機械の上で実際に通るか確かめます。 visudo -cf は文法しか見ません。パスやオプションの並びが1文字違うと、文法は通るのに実行時に弾かれます。sudo -n <コマンド全体> を1つずつ打って、パスワードを聞かれずに通ることを確認するまでが作業です。sudoers に書いたことは、sudo できることの証拠になりません。

sudoers の1行を左から読む図。deploy ALL=(root) NOPASSWD: ALL は、deploy というユーザーが、どのホスト上でも、root として、パスワードを聞かれずに、あらゆるコマンドを実行できる、と読む。これを書くと CI を踏まれたら機械の root を取られ、リポジトリへの書き込み権限が root と同じ意味になる。狭めるのは最後の ALL で、Cmnd_Alias DEPLOY に /usr/bin/install で /tmp/staging-/.yml から /etc/myapp/*.yml へ置く呼び方、/usr/bin/systemctl reload myapp、/usr/bin/myapp-validate /etc/myapp/config.yml を引数まで書いて並べ、deploy ALL=(root) NOPASSWD: DEPLOY でそれを指す。注意が2つあり、引数は1本に連結した文字列として照合されるので * は引数のあいだの空白も / も跨ぐこと、visudo -cf は文法しか見ないので sudo -n で1つずつ打って確かめること

ロールバック: 自分で全部書く

ここが push 型の弱点です。壊れた設定を置いてしまったとき、機械は自力では何もしません。戻す手続きは、配る側が最初から最後まで書くことになります。

1. 既存を *.bak.<時刻> にコピー
2. 新しいものを置く
3. 検証コマンドを通す
4. reload する
5. 健全性エンドポイントを叩いて確認
6. 駄目なら bak から戻して再 reload

書けます。ただしこの手続き自体が途中で死ぬ可能性が残っていて、そこは方式を変えないと消えません。

3で通って4で reload した直後に CI が落ちたとします。落ちたこと自体は分かります。 GitHub Actions なら job が赤くなり、失敗の通知も飛びます。分からないのは機械の側です。新しい設定はもう読み込まれていて、5の確認は走らず、6の復元も呼ばれていません。赤い job を見た人が SSH で入って、どこまで進んだかを調べるところから始まります。弱いのは検知ではなく、機械が自分では戻らないことです。

6段の手続きのうち、1 既存を bak にコピー、2 新しいものを置く、3 検証コマンド、4 reload までが済み、その直後に CI が落ちて、5 健全性を確認と 6 駄目なら bak から戻すは走らない。配る側では GitHub Actions の job が赤くなり失敗の通知も飛ぶので、落ちたことは分かる。機械側では新しい設定がもう読み込まれていて、5の確認も6の復元も走っておらず、人が SSH で入ってどこまで進んだかを調べることになる。弱いのは検知ではなく、機械が自分では戻らないこと

気づけないほうは同時実行です。同じ機械への配布が2本重なると、片方の bak をもう片方が上書きします。それでも両方の job は緑で終わります。壊れているのは次に戻したくなった瞬間で、そのとき bak には自分が置き換えたはずの設定が入っていません。CI のログには最後まで何も出ません。

対策は打てます。同じ機械への配布は直列化する。途中でキャンセルされないようにする。バックアップのファイル名に実行 ID を埋めて1対1で紐づける。打てますが、全部自分で書きます。A/B 型(A/B パーティション)が機械の性質として持っているものを、こちらは手続きで再現していることになるので、書き漏らした穴はそのまま残ります。

同じ機械への配布が2本重なったときの流れ。job A が bak を作り (中身は元の設定)、新しい設定を置いて緑で終わる。その間に job B が bak を作り直し、A が置いた設定で上書きして、これも緑で終わる。あとで A の分を戻したくなったとき、bak に元の設定が無い。壊れているのが分かるのは戻したくなった瞬間で、CI のログには最後まで何も出ない。対策は、同じ機械への配布を直列化する、途中でキャンセルされないようにする、bak のファイル名に実行 ID を埋めて1対1で紐づける、の3つで、全部自分で書く

改ざん耐性: 経路を信じる型

この方式では、配られる中身そのものには何も付いていません。信頼しているのは経路です。VPN の暗号化、SSH のホスト鍵、authorized_keys に載っている公開鍵。

つまり CI の秘密鍵が漏れたら、正規の経路で偽物が配られます。機械側に「これは本当に配る側が作ったものか」を検証する手段がありません。この性質は pull 型やパッケージ型と比べたときにはっきり差が出るので、コード署名と鍵の置き場所: 5方式に共通する穴でまとめて扱います。

観測性: 5方式で一番強い

配る側が最後まで見届ける方式なので、成功も失敗も CI のログに全部残ります。どのファイルを置いたか、検証が通ったか、reload が返ったか、健全性が 200 を返したか。時系列で1本に並びます。

「配ったつもりで届いていなかった」が起きにくいのは、この方式の見落とされがちな長所です。機械が取りに来る方式では、取りに来なかったことに気づく仕組みを別に作る必要があります。

push 型は、確かめた順に CI のログへ1本で並ぶ。配る側が最後まで見届けるので、成功も失敗も、どこまで進んだかも同じ場所に残る。push 型の配る側のログには、どのファイルを置いたか、検証が通ったか、reload が返ったか、健全性が 200 を返したか、が順に並ぶ。機械が取りに来る方式では、配る側のログは置き場所に置いたところで終わり、取りに来なかったことに気づく仕組みは別に作る

7軸で見ると

軸評価補足
到達性△内側に居る機械へは VPN 参加などの工夫が要ります
常駐コスト◎常駐物なし。置くのは公開鍵と sudoers の数行
権限の粒度○sudoers を細かく書けば絞れます。書かないと ALL
ロールバック△全部自分で書きます。手続きの途中死が残ります
帯域◎変わったファイルだけ送れます
改ざん耐性△経路を信じる型。中身に署名はありません
観測性◎CI のログに全部残ります

機械が少なく、配るものが小さく、失敗をすぐ人間が見に行ける状況なら、この方式がもっとも安上がりです。台数が増えるほど観測性の◎が崩れていきますが、その理由は単純で、100台に SSH で入るというのは1台に入るのを100回やることであって、1回ではないからです。

次の章では、向きを反対にします。機械側に取りに来させると、この表のどこが入れ替わるか。