概要
GitHub Actionsは便利ですが、動作中に何か良くないことが起こった場合の公式なデバッグ手段がありません。(競合サービスだとSSHデバッグが付いているのもあるらしい)
そこでtmateというSSHで接続してデバッグできる(非公式の)actionsが公開されていて、これはとりあえずありがたいんですが、通信が遅いのなんの、文字を打って表示されるまで最低でも0.2秒、平均で0.4秒、最悪で1秒以上かかるというような感じで、日本からじゃなければマシなのかもしれないですが、快適なデバッグ環境とは言い難い状況です。
そこでTailscaleを使ってSSH接続できるようにします。
tmateのdetached mode
その前にまずtmateの"detached mode"について説明します。
tmateのアクションはデフォルトだと実行時にユーザーからの接続を待ち続け、一旦接続して切るか10分のタイムアウトが経過するかどっちかしないと続きに移行してくれません。
一方、"detached mode"というのを設定すると(ちなみにオプションだけでなく「mxschmitt/action-tmate/detached」を利用することも可能)、接続準備が完了したら一旦そこでジョブが終了して続きの(lintやビルドなどの)実行に移行し、メインのstepが完了してからもpost jobのような感じで接続待ちをして、10分間接続がなければ終了してくれます。接続している間はずっと待ってくれます。
これならデバッグを有効にしつつとりあえず様子を見て何かエラーが起きたら(あるいは起きそうなので最初から)つなぎに行くみたいなことができて、地味なのですがかなり便利です。tmateのリポジトリの説明にも「もし過去に戻れるならこっちをデフォルトモードにしたいレベル」というようなことが書いてあります。
従ってTailscale向けにもそんな感じの動作を実装してみることにします。
一般的な"timeout"アクションの作成
というわけで、tmateのソースコードを参考に、準備完了・待機のところだけ抽象化した"timeout"というアクションを作ってみました。→actions/timeout at main · ge9/actions · GitHub
これは、info-cmd、check-cmd、keep-cmdという3つ(その他タイムアウト時間など)を指定することができます。
info-cmdというのは、接続に必要な情報を表示するためのもので、最初に一度実行された後、接続待ちのときにも定期的に実行されます。
check-cmdというのは、ユーザーからの接続があるかどうかを確かめるもので、接続待ちのときに定期的に実行されます。
keep-cmdというのは、デフォルトではcheck-cmdと同じで、接続完了後に、ユーザーからの接続を維持するかどうか確認するためやはり定期的に実行されます。
従って、info-cmdにはecho please connect with: ssh XXXXみたいなことを書いて、check-cmd(=keep-cmd)には、sshdプロセスが生きていれば成功ステータスになるコマンドを指定すればいいです。
Tailscale側
Tailscaleは公式にGitHub Actionsを用意してくれているので、各OS対応など含めてGitHub Actions関連でやることはほぼありません。
ただOAuth Clientと権限の設定は必要です。他の記事にもちょくちょく載ってる内容ではあります。
まずTailscaleでは(少なくとも個人プランでは)デフォルトだとすべてのデバイスが全てのデバイスに対して通信を行えるような設定になっています。Access controlsのところを見ると
// Allow all connections.
// Comment this section out if you want to define specific restrictions.
{
"src": ["*"],
"dst": ["*"],
"ip": ["*"],
}
こんな感じになっていると思います。
しかしGitHub Actionsから自分のPCとかに勝手に接続してこられるようにするのはちょっと微妙だし必要もないのでこれを弱めます。
これは簡単にWeb上で編集することができて、srcのほうを例えばautogroup:ownerとかにしてみました。これでtailnetのownerである自分に紐づいたデバイスからしか接続を開始できなくなります。
次にGitHub Actions向けにタグを作成します。Access controlsにTagという項目があるのでそこで任意の名前でタグを作成します。権限が弱いという意味でweakという名前のタグを作ってみました。
接続を待ち受けるためには特に明示的に権限は与えなくていいようなのでAccess controlsに関してこれ以上の設定は不要です。
次にOAuth Clientを作成します。これはまあbotアカウントのようなものです(つまり前述の「自分に紐づいたデバイス」にはならない)。SettingからTrust credentialsを選んで作成開始し、OAuthと選び、権限はAuth KeysのRead/Writeだけ有効にします。このWriteを有効にするときにタグを最低1つ選ぶように言われるのでさっきのweakタグを選びます。
出てきたIDとシークレットをメモします。といっても明らかにIDがシークレットの部分文字列になっているのでシークレットだけ取っておけばいいです(というかIDは後からでも見れます)。後述しますがGitHub Actions側でシークレットの部分からIDも取るように実装しました。このシークレットをGitHub ActionsのRepository TokenにTS_OAUTH_CLIENT_SECRETみたいな任意の名前で設定しておきます。
SSH公開鍵の設定
sshd自体はどのOSでも最初から動いているのですが、鍵は自分で設定する必要があります。
鍵としてはActionsを実行したユーザーのGitHubアカウントに登録されたものを使うというのがtmateで採用されていて、必ずしもそれがベストではない場合もあると思うのですがとりあえず自分ではこれを使ってみることにします。
また鍵の設置にあたってパーミッションなども一応配慮が必要で、またWindowsでは管理者としてログインされるので%PROGRAMDATA%\ssh\administrators_authorized_keysが使用されることにも注意が必要です。この部分もactionsにしておきました。→actions/ssh-pubkey/action.yml at main · ge9/actions · GitHub
完成
今まで準備したものをまとめて、以下のようにします。(そこまで完成されていないのでこれ単体でGitHub Action化はまだしていません)
- name: Tailscale env
id: tailenv
shell: bash
run: |
TS_ID=$(echo ${{ secrets.TS_OAUTH_CLIENT_SECRET }} | cut -d '-' -f 3)
echo ::add-mask::$TS_ID
echo "TS_ID=$TS_ID" >> "$GITHUB_OUTPUT"
echo TS_SSH=$(curl https://github.com/${{github.actor}}.keys) >> "$GITHUB_OUTPUT"
echo TS_HOST=gha-ssh-${{ runner.os }}-$(openssl rand -hex 6) >> "$GITHUB_OUTPUT"
- name: Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ steps.tailenv.outputs.TS_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }}
hostname: ${{ steps.tailenv.outputs.TS_HOST }}
tags: tag:weak
- name: Tailscale SSH set pubkey
uses: ge9/actions/ssh-pubkey@main
with:
ssh_public_key: ${{ steps.tailenv.outputs.TS_SSH }}
- name: Tailscale SSH info
uses: ge9/actions/timeout@main
with:
check-cmd: ${{ runner.os == 'Windows' && 'tasklist /FI "IMAGENAME eq sshd.exe" /V | findstr /I runneradmin'
|| 'pgrep sshd -u runner' }}
info-cmd: "echo ssh ${{ steps.tailenv.outputs.TS_HOST }}"
細かい部分について説明しておきます。TS_IDは前述の通りOAuth SecretからIDだけ取り出したものです。一応maskしておきます。ホスト名はgha-sshという適当なprefixに続いてOS名を入れました。これにより、手元の.ssh/configでgha-ssh-Windows-*に対してはuser runneradminを設定しておくことができます。GitHub Actionsにはジョブ間で独立した一意のIDは無いみたいなので自前で乱数生成します。(追記訂正: macOSにbase32がなかったのでopensslに変更しました)
check-cmdのところは*nixはpgrepでよくて、Windowsはtasklistとfindstrを使っています。パイプが書いてありますがtimeout actionsの内部で使っているNodeのexecはシェルに渡して実行してくれるらしいのでこれで動きます。こんな感じで式を書くこともできるのは便利ですね。
上記の内容を他のActionsの最初のほうに貼り付けておけば、準備完了後に
ssh gha-ssh-Windows-abcd1234efgh5678
みたいなSSH接続用の文字列が表示され、Tailscale経由でのデバッグができるようになります。
環境変数
今回の方法では、GitHub Actionsのメインの実行とは独立にSSHで接続しに行くことになるので、環境変数は反映されません。ちょっと雑(シェル上でカレントディレクトリの表示がおかしくなったりする)ですが、ymlにexport -pを追加して環境変数をファイルにバックアップしてsshの後にそれをsourceすれば元の環境変数を復元できます。
UDPホールパンチングについて
GitHub ActionsのNAT動作を調べてみるとPort-restricted Cone NATのようでした。従って、手元側がPort-restricted Cone NATまたはもっと緩いNATであれば、GitHub Actionsとの間での直接的なUDP接続が成立します。
ちなみに遅延は体感でもtmateと比べて明らかに改善していて、ほぼストレスはないです。
tailscale statusの結果について
今回はタグを使って、runner側から他のTailscaleのデバイスに接続しに行けないように設定しました。しかし、実際に接続したGitHub Actionsのrunnerでtailscale statusを実行してみると、自分の持っているデバイスが全て表示されます。
どうやら、What devices can connect to or know mine? · Tailscale Docsに書いてある通り、ここには「自分自身に接続しに来ることが可能なデバイス」も表示されてしまうようです。おそらくUDPホールパンチングなどで双方向的な通信が必要だからではないかと思いますが、あまり嬉しくないですね。
もし、一部デバイスからしかGitHub Actionsには接続しに行かないので、それ以外の余計なデバイスを隠したい、ということであれば、ACLでIPアドレスを直接指定するか、あるいはIP setにまとめるか、あるいはhost名を付けてIP setにまとめるかすることで、一部デバイスのみ対象にすることができます。tagを割り当てるとユーザー所有デバイスとは違う扱いになってしまうのであまり良くないです。ただ、tag付きの別ノードを経由してつなぎに行くという選択肢はあるかもしれません(たとえば【Android】非rootなTermuxでTailscaleを動かす - turgenev’s blogで紹介したts-proxyを使うこともできます)。
また、生IPではなくホスト名(DNS名)で指定できればいいのですがそれはできないようです。issueとしては、#2556がこれを扱っているほか、逆にtagをもっと使いやすくするという方向性では#5321や#10695、両者の中間的な#5180などがあります。