
2026/01/12 21:25
GitHub Actions でデバッグターミナルを起動する。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
(以下は日本語訳です)
Summary
プロジェクトは、https://actions‑term.gripdev.xyz/ で失敗した GitHub Actions の実行をデバッグするための無料かつオープンソースの Web ターミナルを提供します。WebRTC ピア・ツー・ピア接続(UDP ホールパンチング、Tailscale/iRoh/WebRTC を介して)を確立することで、すべてのターミナルトラフィックがブラウザとランナー間で直接流れ、コストの高いサーバー経由ではなくなるため、データ転送コストをほぼゼロに保ちます。
認証は分離されています。ブラウザは GitHub OAuth を介して認証し、ランナーは GitHub Actions OIDC トークン を使用して GitHub の JWKS に対して検証します。軽量な Go シグナリングサーバーは接続メタデータのみを交換し、セッションマップ(
runIdToSessions、runIdRunnerSseClient など)を保存した後、Server‑Sent Events (SSE) を通じてブラウザへ更新情報をプッシュします。
ターミナルのレンダリングは Ghostty(xterm.js と互換性のあるライブラリ)で行われます。これはブラウザのフォントメトリクスからターミナル寸法を計算し、PTY を生成する前にこのサイズ情報をランナーへ送信します。これにより、最初から正確な表示が保証されます。
ゼロトラストセキュリティのために、各ピアは共有シークレットから導出されたワンタイムパスワード(OTP)を提示します。ランナーはターミナルアクセスを許可する前にこの OTP を検証し、シグナリングサーバーが侵害されても悪用を防ぎます。
サービスは Railway.app 上で動作しており、実際の CPU/メモリ使用量のみ課金され、アイドル時には「スリープ」状態に入ります。ピークメモリ消費は約 20 MB にとどまり、月額コストはほぼゼロ(≈$0.00000)です。コールドスタートも短時間であるため、ユーザーはセッションを開始する際にわずかな遅延しか経験しません。
インパクト: 開発者は追加のインフラオーバーヘッドなしに CI の失敗を即座かつ無料でデバッグできるようになります。企業は運用コストを削減し、継続的インテグレーションパイプラインのセキュリティ姿勢を強化できます
本文
ネタバレ: GitHub Actions が失敗したときにインタラクティブな Web ターミナルを取得できる、無料かつオープンソースの方法を作成しました。試してみてください: https://actions-term.gripdev.xyz/ (コード 🔗)
作り方
「ビルドが Actions で失敗するけどローカルでは動く」という状況は誰もが経験したことがあると思います。結果として、以下のような遅いループに陥ります。
推測的変更をプッシュ うまくいったか確認
そのループの中で、もっと良い方法を考え始めました。
ターミナルは明らかに便利ですが、どうすれば実現できるでしょう? さらに、大きなコストを発生させずに誰でも無料・オープンに保てる方法は?
- ユーザーと Actions VM の間でトラフィックを転送するサービスを運用するとデータ転送料金が増加し、スケール作業も必要になります。
- P2P 接続はどうでしょう? Tailscale、iroh、WebRTC は UDP ホールパンチングを利用して中継なしで P2P を確立します。これならサーバー側はセッションごとにほんの少しだけ情報交換すればよく、費用もほぼゼロです。
Actions VM がインターネット上にあり UDP アウトバウンドを許可していることが判明したので、動作するはずでした。簡単なスクリプトで検証しました:
// ... WebRTC ICE 候補交換 ...
セキュリティとアイデンティティ
次の問題:P2P 接続の両端が主張する通りに本人確認できるか?
-
ブラウザ側: GitHub で OAuth を使うことで認証済みユーザー名を取得します。
-
Actions VM 側: OIDC(Actions がクラウドプロバイダーへ認証する際によく使用)で署名付きトークンを発行し、以下を証明できます。
- 実行中のリポジトリ
- トリガーしたユーザーアカウント
- 対象とするオーディエンス
ワークフローにこれを有効化します:
permissions: id-token: write
Action 内でトークンを要求(例):
const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const SERVER_URL = 'https://actions-term.gripdev.xyz'; const url = new URL(requestURL); url.searchParams.set('audience', SERVER_URL); const resp = await fetch(url.toString(), { headers: { Authorization: `Bearer ${requestToken}`, Accept: 'application/json', }, });
JWKS を介してトークンを暗号的に検証します:
githubOIDCIssuer := "https://token.actions.githubusercontent.com" githubJWKSURL := "https://token.actions.githubusercontent.com/.well-known/jwks" // JWKS を取得 keySet, err := jwkCache.Get(ctx, githubJWKSURL) if err != nil { return "", "", "", fmt.Errorf("failed to fetch JWKS: %w", err) } // パースと検証(クロックスキュー許容) parseOpts := []jwtx.ParseOption{ jwtx.WithKeySet(keySet), jwtx.WithIssuer(githubOIDCIssuer), jwtx.WithValidate(true), jwtx.WithAcceptableSkew(2 * time.Minute), jwtx.WithAudience(oidcExpectedAudience), } token, err := jwtx.Parse([]byte(tokenStr), parseOpts...) if err != nil { return "", "", "", fmt.Errorf("token validation failed: %w", err) }
ピアを接続(=シグナリングサーバ)
ここまでで分かったこと:
- Actions VM ↔ ブラウザ間に WebRTC 接続が確立できる。
- 両端のアイデンティティ検証手段がある。
残るのは、二者を紹介するサーバです。
サーバは単に紹介だけを行い、ターミナルデータはピア同士で直接流れます。
VM とブラウザがサーバへ接続すると、Server‑Sent Events(SSE)でそれぞれ相手の接続情報を送信します。両者は OAuth 認証情報や OIDC トークンを提供してアイデンティティを証明します。
サーバ側状態:
runIdToSessions = make(map[string]*Session) // runId -> session runIdToSessionsMu sync.RWMutex runIdRunnerSseClient = make(map[string]*SSEClient) // runId -> SSE client (Actions VM) runIdRunnerSseClientsMu sync.RWMutex actorToBrowserSseClients = make(map[string][]*SSEClient) // actor -> list of browser SSE clients actorToBrowserSseClientsMu sync.RWMutex
新しい Actions VM が接続すると、待機中のブラウザに通知します:
runIdRunnerSseClientsMu.Lock() runIdRunnerSseClient[runId] = client log.Printf("SSE: Runner connected for actor %s (total clients: %d)", actor, len(runIdRunnerSseClient)) runIdRunnerSseClientsMu.Unlock() // ブラウザサブスクライバーへ新セッションを通知 sess, ok := runIdToSessions[runId] if ok { notifyNewSession(sess) }
ターミナルの表示
シグナリングサーバと P2P 接続が揃ったので、ターミナルを作成してデータをストリームします。
WebRTC の DataChannel を使い、Actions VM 側で PTY シェルを生成し、そこからデータを送信:
shell = pty.spawn(SHELL, [], { name: 'xterm-256color', cwd: process.env.GITHUB_WORKSPACE || process.cwd(), env: process.env as Record<string, string>, }); shell.onData((shellData) => { dc.sendMessage(shellData); });
ブラウザ側では Ghostty(xterm.js 互換)を使ってインタラクティブターミナルを表示します。
フォントサイズから端末サイズを推定し、設定用 JSON を送信して Actions VM が正しい寸法でシェルを起動できるようにしています。
信頼対ゼロトラスト
シグナリングサーバは、ユーザーが開始した Actions とのみ接続する必要があります。そうしないと誰かが接続を乗っ取れる恐れがあります。
対策として:
- ユーザーは Actions VM に秘密(自分だけ知っているもの)を渡します。
- P2P 接続確立時、Actions VM は有効な OTP が届くまで通信を拒否します。
OTP は 2FA のように使われる一回限りパスワードで、シグナリングサーバが侵害されても正しい OTP を持たないとコマンド実行できません。
フロー:
GitHub Runner <─── WebRTC 接続確立 ───────> ブラウザ ▲ │ │ ブラウザが OTP を送信 │ │ ▼ Runner が秘密に対して OTP を検証 → 有効ならターミナルアクセス許可
シグナリングサーバは OTP もその秘密も受け取らないので、検証は Actions VM とブラウザの間で直接行われます。
シグナリングサーバのホスティング
無料で提供したい。サーバは Go バイナリを Docker イメージに入れただけなので、自前で簡単にローカルホストできます。
Railway.com なら実際に使用した CPU/メモリ分だけ課金されます(Azure/AWS のようにリザーブが必要なし)。
ピークメモリは約 20 MB で、費用はほぼゼロ($0.00000 程度)です。 Railway は「スリーピング」機能もあるため、アイドル時にはサーバを停止し、需要に応じて立ち上げることでコストを最小化できます。
クールスタートはほぼ無感覚で、サーバがスリープから復帰するときだけ短い遅延があります。