macOSからext4パーティションを直接編集する(Linux VM不要)
ラズパイの秘密鍵を紛失して、/root/.ssh/authorized_keys に新しい公開鍵を追加したくなった。SDカードはUSBリーダーでMacに直接挿している。diskutil list でパーティション構成は見えるが、ルートパーティションはext4なのでmacOSは中身を読めない。手元にLinuxマシンはなく、あるのはDocker Desktopだけ(Apple SiliconのDocker DesktopはLinuxカーネルを積んだVMを動かしているだけで、コンテナから /dev/disk5s3 のようなホストのブロックデバイスに直接触ることはできない)。この記事は、そのどちらもなしで解決した手順の話。
仕掛け: e2fsprogsはユーザー空間実装
e2fsprogs に入っている e2fsck や debugfs は、カーネルのext4ドライバに一切依存していない。ext2/3/4のオンディスクフォーマットを丸ごとユーザー空間で実装していて、渡されたパスに対して read()/write()/lseek() するだけ。つまり対象が普通のファイルでも、ディスクイメージでも、macOSのraw deviceノードでも同じように動く。
brew install e2fsprogs
# keg-onlyなのでフルパスで呼ぶ
DEBUGFS=/opt/homebrew/opt/e2fsprogs/sbin/debugfs
diskutil list で見ると、対象は disk5、3番目のスライスがLinuxのルートパーティションだった。
/dev/disk5 (external, physical):
1: Windows_FAT_32 ... disk5s1
2: Linux_Swap ... disk5s2
3: Linux ... disk5s3
マウントせずに読み取り専用で中を覗ける。
$DEBUGFS -R "ls -l /root/.ssh" /dev/rdisk5s3
$DEBUGFS -R "dump /root/.ssh/authorized_keys ./authorized_keys.bak" /dev/rdisk5s3
ls -l・stat・dump だけで、inode番号・パーミッション・タイムスタンプ・ファイルの中身まで全部取れる。今回は既存ファイルへの1行追記なので、ダンプした内容にローカルで新しい鍵をマージし、debugfs -w で書き戻した。
$DEBUGFS -w -R "rm /root/.ssh/authorized_keys" \
-R "write ./authorized_keys.merged /root/.ssh/authorized_keys" \
-R "sif /root/.ssh/authorized_keys mode 0100600" \
-R "sif /root/.ssh/authorized_keys uid 0" \
-R "sif /root/.ssh/authorized_keys gid 0" \
/dev/rdisk5s3
仕組みとしてはこれで全部。ただしこの過程で2つハマりどころがあって、大事なファイルシステムで試す前に知っておいた方がいい。
ハマりどころ1: 連結した-Rはカレントディレクトリを共有しない
debugfs -w は -R を複数指定すると1回の起動で複数コマンドを実行できる。対話セッションと同じ感覚で、cd した後は以降のコマンドにも効くと思っていた。
$DEBUGFS -w -R "cd /root/.ssh" -R "write ./authorized_keys.merged authorized_keys" /dev/rdisk5s3
結果、エラーも出ないままファイルは /authorized_keys(ルート直下)に書かれていた。cd はコマンドの境界を越えて効かない。教訓: debugfs -w を複数-Rでスクリプト化するときは、write/rm/sif には必ずフルパスを渡す。そして「コマンドが成功したはず」を信用せず、書き込みのたびに ls -l で確認する。
ハマりどころ2: raw deviceとjournal replayが自分の編集を巻き戻す
もっと大きな問題はここから。編集後、念のため整合性チェックをかけた。
e2fsck -y -f /dev/rdisk5s3
recovering journal と出た後、unable to set superblock flags on /dev/rdisk5s3 でエラーが残ったまま終了した。その後ファイルを読み直すと、inodeの中身が全く無関係な、ずっと前に削除された別のファイルのメタデータに巻き戻っていた。所有者は違うし dtime(削除時刻)はセットされているし、リンク数は0。書いたはずの authorized_keys は消えていた。データブロック自体は無事だったのに。
何が起きたか。debugfs -w はブロックに直接書き込み、ext4のjournalを完全にバイパスする。稼働中のext4はほぼ常にjournalの中身が空ではない——これは正常終了していなくても普通に起きることで、破損の兆候ではない。誰もそのjournalをリプレイしない限り、中身が古かろうが空だろうが害はない。ところが e2fsck -y はリプレイする。そして、そのトランザクションがたまたま自分が手編集したのと同じinodeテーブルのブロックやビットマップを触っていたら、リプレイが勝つ。journalに記録されていた古い状態でそのブロックが上書きされる。
superblockのフラグ書き込みが失敗した理由は、macOS特有の癖だった。/dev/rdiskN はcharacter(raw)デバイスで、e2fsprogsが最終処理時に出す小さくて中途半端なサイズの書き込み(今回は「要リカバリ」フラグのクリア)が、EINVAL で弾かれることがある。macOSのraw deviceはセクタサイズにアラインされたI/Oを要求するため。block deviceのノードに切り替えるだけで直った。コードは何も変えていない。
e2fsck -y -f /dev/disk5s3 # buffered block device。/dev/rdisk5s3ではない
buffered device経由だとカーネルのバッファキャッシュがアラインされていない書き込みを吸収してくれて、journal replayもsuperblockの更新もクリーンに完了した。
正しい手順
2つのハマりどころを踏まえると、稼働中のext4イメージを debugfs -w で手編集する安全な手順はこうなる。
-
先にjournalを回収する。手で何か触る前に
e2fsck -y -f /dev/diskNsM(buffered device)を実行し、保留中のjournaledトランザクションを全部flushしておく。これで後から自分の編集が巻き戻される心配がなくなる。 -
編集する。
debugfs -wで全コマンドにフルパスを使い、変更のたびに読み取り専用のls -l/statで確認する。 -
読み取り専用チェックで検証する。
e2fsck -n -f /dev/diskNsMがクリーンなら完了。「念のため」でもう一度e2fsck -yを走らせたくなるが、それは我慢する。直すものは何も残っていないし、再実行しても得るものはなくリスクだけが増える。
# 1. journalをクリーンな状態にする
e2fsck -y -f /dev/disk5s3
# 2. 編集(フルパスのみ)
debugfs -w -R "write ./authorized_keys.merged /root/.ssh/authorized_keys" \
-R "sif /root/.ssh/authorized_keys mode 0100600" \
-R "sif /root/.ssh/authorized_keys uid 0" \
-R "sif /root/.ssh/authorized_keys gid 0" \
/dev/disk5s3
# 3. 読み取り専用で確認。これ以上書き込まない
e2fsck -n -f /dev/disk5s3
まとめ
-
e2fsprogs(debugfs、e2fsck)はext2/3/4を丸ごとユーザー空間で実装しているので、カーネルのext4ドライバもLinux VMもなしに、macOSのraw deviceノードに対してそのまま動く。 -
debugfs -wを複数-Rで連結しても、カレントディレクトリは共有されない。必ずフルパスを使い、変更のたびに確認する。 -
debugfs -wはjournalをバイパスする。その後e2fsck -yを実行するとリプレイが走り、自分の手編集が保留中のjournaledトランザクションと同じブロックにあれば静かに巻き戻される。journalの回収は編集の前にやる。 - macOSでは
e2fsck/debugfsの書き込みに、raw character device(/dev/rdiskN)よりbuffered block device(/dev/diskN)を使う方がいい。raw deviceのアラインメント要求で、小さいメタデータの書き込みがEINVALで弾かれることがある。