Write and Run

it's a simple way, but the only way.

リモートマシンから手元に向かってコマンドを実行できるやつを作った

KOBA789 です。寒い日が続きますね。こうもあまりに寒いとアイスを食べたくなるものです。昨日の私はその衝動に抗えず、コンビニでソフトクリーム(チョコ味とのミックス)を買ってきて食べました。余計に寒くなったのでもう二度とやりません。今はおでんが食べたいです。よろしくお願いします。

リモートから手元に向かってコマンドを実行したい

さて、寒いとアイスが食べたくなるように、リモートマシンに SSH でログインしていると手元でコマンドを実行したくなるものです。せっかくリモート接続してるのにね。人って不思議です。

たとえば、SSH 先の Linux マシンで code って打ったら手元の MacBook AirVS Code が起動してほしいわけです。VS Code の Integrated Terminal 内ならできますけど、そもそも VS Code のウィンドウが1枚も開いていないときには使えない技です。

Alacritty で SSH して、cd でプロジェクトのディレクトリまで潜って、さぁやるぞと思って code . と叩くと zsh: command not found: code の響きあり。がーんだな……出鼻をくじかれた。ただでさえ寒くてお布団から出たくない*1のにこれでは仕事する気になるわけがありません。エンジニアリングでなんとかしましょう。MacBook AirLinux サーバーを併用するなんていうトリッキーな開発環境でなければ困らない問題ですが、困っているのでなんとかするしかありません。

さらにいうと、KOBA789 は AWS の認証情報管理に aws-vault というツールを使っています。これはアクセスキーやセッショントークンを OS のセキュアなキーチェーンに保存してくれて Credential Provider の一種である Process credentials として振る舞えるというスグレモノです。要はキーチェーンに保存しておいたクレデンシャルを AWS CLIAWS SDK から透過的に使えるというわけです。

さて話が脱線しているようにも見えますがそうでもないのでお付き合いください。この aws-vault で使うキーチェーンが問題です。macOS であれば標準の Keychain Access.app 一択ですが、Linux ではいろいろな選択肢があり困ります。しかもどれもデスクトップ環境のない(= headless な)環境だと使いづらいです。ここはせっかくなので SSH 先の Linux マシンでも macOS のキーチェーンを使いたいですよね。あれ、これってもしかして VS Code の問題と同じ手法で解決できるのでは?

SSH Agent Protocol

そうと決まれば次はどうやって実現するかが問題です。SSH と関係ないサイドチャネルの通信でどうにかするのはセキュリティの観点からナシでしょう。せっかく Secure SHell のセッションがあるのですからこれを使うべきです。

では逆向きに SSH セッションを張るというのはどうでしょうか。リモートの Linuxssh temoto-machine するというイメージです。悪くはないように見えますが、リモートマシンから手元のマシンへの接続性が常にあるとは限りません。頻繁に持ち歩き、時には信用できないネットワークにも接続するかもしれない MacBook AirSSH のポートを開けておくというのはナンセンスでしょう。これもボツです。

というわけで見出しに書いた手法 SSH Agent Protocol目的外利用します。

SSH Agent Protocol とはその名の通り ssh-agent で使われているプロトコルで、ForwardAgent yes すると SSH 先のリモートマシンでも手元のマシンにしかない秘密鍵が使えるようになったりするアレです。元々そういう目的のプロトコルなので、伝送路はセキュアであると考えていいでしょう。

肝心のプロトコルの仕様については以下の IETF のページにあります。微妙に曖昧というか細かいところの詰めが甘いような気もしますが、そこは実地でパケットキャプチャしたりしてなんとかすればよいのです。

draft-miller-ssh-agent-04

プロトコルの大枠だけざっくり解説しておくと、フレームの長さに続いてフレームのボディーが流れてくるシンプルなフレーミングを用いて、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 の空き枠を見つけただけですが。

github.com

まず 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 -- hostname
temoto-machine

きっと手元のマシンで実行した結果が得られるはずです。得られなかったらバグってます。残念でしたね。

Agent から Client へは任意のタイミングで送信できない問題

しくみとしては前述のとおりです。が、実は意外とテクい部分があったのでご紹介します。

SSH Agent Protocol は Client 主導のリクエスト・レスポンスモデルです。つまり、Agent の好きなタイミングで Client にメッセージを Push することは許されません。HTTP と同じセマンティクスだと思ってもらえれば納得しやすいでしょう。

しかしその通信モデルでは stdout や stderr の配送で困ります。それらは任意のタイミングでバイト列が発生しますが、これを Client に伝える術がありません。まぁぶっちゃけコネクションは張りっぱなしなのでプロトコルを無視すれば送れてしまうんですが、とりあえず足掻いてみましょう。

Client から Agent に送れるリクエストの種類のひとつとして、WATCH というものを定義しました。これを送信すると、Agent は

  1. 子プロセスの stdout から出力が発生
  2. 子プロセスの stderr から出力が発生
  3. 子プロセスが死んだ(exited)

のいずれかのイベントが起きるまで、レスポンスを返さずに黙り込みます。昔のウェブで使われた 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 の仕様に違反した実装しても誰も困らんのではないか……

おまけ

SSH 先から手元の VS Code を起動する設定

以下のようなシェルスクリプトreverse-code命名して PATH の通ったところに置きます。REMOTE_HOSTSSH 先のホスト名に置き換えてください。

#!/usr/bin/env bash

ssh-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-code
fi

SSH 先から手元の aws-vault を使う設定

~/.aws/config にこんな感じで書けばいいと思います。PROFILE_NAME, TEMOTO_PROFILE_NAME は置き換えてください。

[profile PROFILE_NAME]
credential_process = ssh-rev exec -- aws-vault exec --no-session --json TEMOTO_PROFILE_NAME

*1:KOBA789 の寝室にはエアコンがありません

*2:机上では成立することが確認できたこと

*3:†リアルタイムウェブ†