KOBA789 です。寒い日が続きますね。こうもあまりに寒いとアイスを食べたくなるものです。昨日の私はその衝動に抗えず、コンビニでソフトクリーム(チョコ味とのミックス)を買ってきて食べました。余計に寒くなったのでもう二度とやりません。今はおでんが食べたいです。よろしくお願いします。
さて、寒いとアイスが食べたくなるように、リモートマシンにSSH でログインしていると手元でコマンドを実行したくなるものです。せっかくリモート接続してるのにね。人って不思議です。
たとえば、SSH 先のLinux マシンでcode って打ったら手元のMacBook Air でVS Code が起動してほしいわけです。VS Code の Integrated Terminal 内ならできますけど、そもそもVS Code のウィンドウが1枚も開いていないときには使えない技です。
Alacritty でSSH して、cd でプロジェクトのディレクトリまで潜って、さぁやるぞと思ってcode . と叩くとzsh: command not found: code の響きあり。がーんだな……出鼻をくじかれた。ただでさえ寒くてお布団から出たくない*1のにこれでは仕事する気になるわけがありません。エンジニアリングでなんとかしましょう。MacBook Air をLinux サーバーを併用するなんていうトリッキーな開発環境でなければ困らない問題ですが、困っているのでなんとかするしかありません。
さらにいうと、KOBA789 はAWS の認証情報管理にaws-vault というツールを使っています。これはアクセスキーやセッショントークンを OS のセキュアなキーチェーンに保存してくれて Credential Provider の一種である Process credentials として振る舞えるというスグレモノです。要はキーチェーンに保存しておいたクレデンシャルをAWSCLI やAWSSDK から透過的に使えるというわけです。
さて話が脱線しているようにも見えますがそうでもないのでお付き合いください。このaws-vault で使うキーチェーンが問題です。macOS であれば標準の KeychainAccess.app 一択ですが、Linux ではいろいろな選択肢があり困ります。しかもどれもデスクトップ環境のない(= headless な)環境だと使いづらいです。ここはせっかくなのでSSH 先のLinux マシンでもmacOS のキーチェーンを使いたいですよね。あれ、これってもしかしてVS Code の問題と同じ手法で解決できるのでは?
そうと決まれば次はどうやって実現するかが問題です。SSH と関係ないサイドチャネルの通信でどうにかするのはセキュリティの観点からナシでしょう。せっかくSecure SHell のセッションがあるのですからこれを使うべきです。
では逆向きにSSH セッションを張るというのはどうでしょうか。リモートのLinux でssh temoto-machine するというイメージです。悪くはないように見えますが、リモートマシンから手元のマシンへの接続性が常にあるとは限りません。頻繁に持ち歩き、時には信用できないネットワークにも接続するかもしれないMacBook Air でSSH のポートを開けておくというのはナンセンスでしょう。これもボツです。
というわけで見出しに書いた手法SSH Agent Protocol を目的外利用します。
SSH Agent Protocol とはその名の通りssh-agent で使われているプロトコルで、ForwardAgent yes するとSSH 先のリモートマシンでも手元のマシンにしかない秘密鍵が使えるようになったりするアレです。元々そういう目的のプロトコルなので、伝送路はセキュアであると考えていいでしょう。
肝心のプロトコルの仕様については以下のIETF のページにあります。微妙に曖昧というか細かいところの詰めが甘いような気もしますが、そこは実地でパケットキャプチャしたりしてなんとかすればよいのです。
プロトコルの大枠だけざっくり解説しておくと、フレームの長さに続いてフレームのボディーが流れてくるシンプルなフレーミングを用いて、1リクエスト=1フレームに対して1レスポンス=1フレームを返すというだけのシンプルな仕様です。レスポンスはリクエストと同じ順で返さねばなりません。
このSSH Agent Protocol には拡張機能のための仕様があり、message type =SSH_AGENTC_EXTENSION(27) としたメッセージの中身に拡張機能の識別子と任意のデータを詰めて送っていいことになっています。任意のデータを送れるので、つまりなんでもアリです。ここにコマンド名や引数を付けて送信し、stdout や stderr を返してもらえば目的は達成できそうです。
論理できた*2のであとは yaru-dake です。日曜日を潰して実装しました。
あ、この記事は Rust Advent Calendar 2022 2枚目 day5 の記事です。なので Rust で実装しました。本当は発表の場がなくて困っていたところに Advent Calendar の空き枠を見つけただけですが。
まず agent(手元)側で次のように起動してSSH します。
$ echo $SSH_AUTH_SOCK/home/user/.ssh/agent$ ssh-rev agent --ssh-rev-sock /path/to/rev-sock$ export SSH_AUTH_SOCK=/path/to/rev-sock$ ssh remote-host
そして client(リモート)側で次のようにしてコマンドを実行します。
$ ssh-rev exec -- hostnametemoto-machine
きっと手元のマシンで実行した結果が得られるはずです。得られなかったらバグってます。残念でしたね。
しくみとしては前述のとおりです。が、実は意外とテクい部分があったのでご紹介します。
SSH Agent Protocol は Client 主導のリクエスト・レスポンスモデルです。つまり、Agent の好きなタイミングで Client にメッセージを Push することは許されません。HTTP と同じセマンティクスだと思ってもらえれば納得しやすいでしょう。
しかしその通信モデルでは stdout や stderr の配送で困ります。それらは任意のタイミングでバイト列が発生しますが、これを Client に伝える術がありません。まぁぶっちゃけコネクションは張りっぱなしなのでプロトコルを無視すれば送れてしまうんですが、とりあえず足掻いてみましょう。
Client から Agent に送れるリクエストの種類のひとつとして、WATCH というものを定義しました。これを送信すると、Agent は
のいずれかのイベントが起きるまで、レスポンスを返さずに黙り込みます。昔のウェブで使われた Comet と呼ばれるテクニックと同じですね*3。
一見うまく動作しそうなこの設計にもまだ問題があります。Client は任意のタイミングで stdin のバイト列を書き込みたいのです。先述のとおり、SSH Agent Protocol ではレスポンスの順番を入れ替えてはいけません。WATCH のレスポンスが返ってくるまでは stdin の書き込みが成功したかどうかわかりません。
そこで、WATCH は stdin の書き込みリクエストでキャンセルできることにしました。WATCH のレスポンス待ち中に stdin の書き込みリクエストを投げると、先行するWATCH に対応するレスポンスはCancelled が返るようにしました。
さあこれで解決、となればよかったのですが、これでもまだまだ問題があります。次のようなケースを考えます。
$ dd if=/dev/zero of=/dev/stdout bs=1M count=100 | ssh-rev exec -- cat > /dev/null
大量のデータを手元に送りつけ、cat で折り返してリモートに送り返すというシナリオです。一見うまく動きそうに見えますが実は途中で詰まります。さぁなぜでしょうか。この原因究明は読者の課題とします。
……とするとブーイングが飛んできそうなのでちゃんと解説すると、このような stdin への書き込みが頻発するようなシナリオではWATCH リクエスト、つまり stdout の読み出しリクエストがすぐにキャンセルされ、stdin の書き込みが圧倒的に有利になります。その結果、stdout を読み出すチャンスがないまま cat のバッファが埋まり、やがて stdin への書き込みが永久に完了しなくなるのです。わかりましたか、読者?
ちなみにこれをうまく解決するプロトコルは実装しておらず、未解決です。もし使う人がいたら気をつけてください。
SSH Agent Protocol の仕様に違反した実装しても誰も困らんのではないか……
以下のようなシェルスクリプトをreverse-code と命名して PATH の通ったところに置きます。REMOTE_HOST はSSH 先のホスト名に置き換えてください。
#!/usr/bin/env bashssh-rev exec -- code --remote ssh-remote+REMOTE_HOST "$(readlink -f $1)"
そして、.zshrc に以下のようなコード片を入れています。Integrated Terminal 内でだけ使える code コマンドを殺さないようにしているわけです。
if ! command -v code &> /dev/null; then alias code=reverse-codefi
~/.aws/config にこんな感じで書けばいいと思います。PROFILE_NAME,TEMOTO_PROFILE_NAME は置き換えてください。
[profile PROFILE_NAME]credential_process = ssh-rev exec -- aws-vault exec --no-session --json TEMOTO_PROFILE_NAME
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。