SSH接続をWebブラウザの純粋なHTTP上で実現する
SSH over HTTPSの世界
HTTPといえばHTML/CSS/JavaScriptや画像などの小さめの限りがあるデータを手に入れるためによく使われている印象がある。REST APIのようなHTTPを使ったAPIでも限りのあるデータがリクエストとレスポンスになる印象が強い。
一方、Webでのリアルタイムな通信と言えば Web Socket や Web RTC で実現する印象がある。
ここではHTTPでのリアルタイムなデータ通信(HTTPでのストリーミング)が可能であることをSSHの接続ができることを通して広めたい内容になっている。
以下のデモでは、SSHをWebブラウザで行い端末を操作している。WebSocketではなく純粋なHTTPSのボディにSSHの通信が流れている。SSH over HTTPSが実現されている。
https://gyazo.com/6934477f2cc13bddb41122b01d1c6a74
vimやtmuxなども普通に動く。
このページの内容は@Cryolite氏の「Piping Server を介した双方向パイプによる,任意のネットワークコネクションの確立 - Qiita」をWebブラウザで実現する内容になっている。提案者の@Cryolite氏に感謝。
実際のアプリケーション
以下で試せる。
GitHub:
後に述べるfetchのStreaming uploadの機能のために、Chrome 85以上が必要。
使用するには、chrome://flags/にアクセスして以下の「Experimental Web Platform features」をEnabledにする。
https://gyazo.com/561cca07e57978fbc8e5b816111dc672
FetchUploadStreaming
純粋なHTTPでのリアルタイム通信
HTTPでのリアルタイム通信を支える技術としてPiping ServerというHTTPサーバーが使われている。
以下のように、Piping Serverに POST /mypath で流したデータが GET /mypath で受け取れるという仕組み。(POSTでもPUTでも同じように転送可能)
https://gyazo.com/ec6149369c9e09fbd86339d24a82dfbc
以下はシンプルな例で、 curlコマンド を使って入力する「ABC..」がブラウザ側で受け取れる。Webブラウザ自体は というテキストのページを開いているだけ。
https://gyazo.com/7bd2cde1cd5a373f0764498bb3c26970
(終了は curl側でCtrl + DしてEOFしている)
以下のようにテキストに限らずバイナリの画像も送れる。Webブラウザは画像を開いたつもりになっている。
https://gyazo.com/645e88c48fcf4afd32b1efd55f9f9bb3
(POSTとGETはどちらが先でも良い)
Piping Serverを使うことで無限のデータをリアルタイムに転送できる。
CLIのcurl/Android/iPhone/Windows/Mac/のWebブラウザやiOSのShorcutアプリ・Microsoft Flowなど自動化アプリや各言語のHTTPライブラリ間などなど、あらゆるデバイス間でデータ転送ができる。
Piping Server - GitHub
以下で公開している。
https://gh-card.dev/repos/nwtgck/piping-server.svg https://github.com/nwtgck/piping-server
自前で立てるときは「docker run -p 8080:8080 nwtgck/piping-server」。
Docker以外の方法:Piping Serverを自前でホストする方法をいくつか
このPiping Serverを「Piping Server を介した双方向パイプによる,任意のネットワークコネクションの確立 - Qiita」の手法を用いて任意のポートを転送する。
仕組みは、サーバーホストのポートに流すべきデータをGET /xxxで受け取り、ポートから出て行くデータをPOST /yyyで送信する。WebブラウザでSSHするためには、POST /xxxでネゴシエーションやユーザーの入力をSSHのプロトコルとして送信して、GET /yyyで受け取れるSSHのプロトコルを解釈してターミナルの画面を構成すれば良い。
Webブラウザで完結するSSHクライアント実装
Piping Serverとホスティング以外にサーバーを用意しない方針のため、SSHを話すプロキシサーバーは作らない。そのためWebブラウザからSSHを話せる必要がある。
ブラウザでSSH出来るOSSを探すと、huashengdun/webssh や billchurch/webssh2 が出てくる。
だがソースを読んだ感じだと、両者ともsshクライアントの実装がサーバー側になっている。webssh は Pythonの paramiko が使われ、webssh2はnpmのssh2(Node.js向け)が使われている。つまりこれらのライブラリではWebブラウザが直接SSHを話さない設計になっているようだった。
SSHy
SSHyというプロジェクトを発見した。
https://gh-card.dev/repos/stuicey/SSHy.svg https://github.com/stuicey/SSHy
ずっとWebブラウザでのJavaScriptでのSSHクライアント実装を探していて、求めていたものだった。
SSHyはブラウザ側で暗号化やSSHを話していて、通信がWebSocketのプロキシに向くようになっていた。このプロキシはWebSocketをTCPに変える汎用的なプロキシ。つまり、このWebSocketに流れるデータをPiping Serverに流れるように変えることで純粋なHTTPでの通信に切り替えられる。
SSHyは現在のところ開発は活発ではないが、SSH over HTTPSの 概念実証(PoC)には十分で、コードも小さくいじりやすいようにできていた。より本格的にやるならOpenSSHをEmscriptenなどでポートしたものや標準ライブラリで安定して使われているものをWebAssembly化する手段に置き換えれば良いのではないかと思う。
実装の透明性という利点
WebブラウザのJavaScriptでSSHクライアントの実装が完結する大きな利点は、SSHクライアントの実装が透明であることだと考えている。つまり、
中継するプロキシサーバーを信頼できなくても構わない。
websshやwebssh2のような実装ではプロキシサーバーにパスワードなどの情報が渡るはずである
ユーザーがやろうと思えばSSHの実装を検証できる。
などが透明性の良さ。
SSHサーバーとWebブラウザ間でSSHというプロトコルの信頼の元でエンドツーエンド暗号化される。
JavaScriptでのHTTPのストリーミング
ブラウザでのJavaScriptでの話。
HTTPでのリアルタイムな通信(HTTPのストリミーング)はシンプル。
受け取るときは以下で出来る。(そこそこ前からある機能)
code:js
const res = await fetch("https://...");
const readableStream = res.body;
fetch()は axios とは違ってWeb標準の機能。res.bodyが ReadableStream になっていてバイト列のストリーム。
以下でreadableStreamが送信ができる。
code:js
fetch("https://...", {
method: "POST",
body: readableStream,
});
上記のReadableStreamの送信が最近Chrome (85)でOrign trailで入った機能。今までBlobやstringをbody: に指定できたが ReadableStream がこれからは送信できるようになる。Firefox/Safariもこれに関してはポジティブに関心を示していて将来的導入されると考えられる。
実例付きの詳細:「Webブラウザ上で純粋なHTTPだけで単方向リアルタイム通信を可能にするHTTPのストリーミングアップロードが遂にやってくる」
SSHyをWebSocketから純粋なHTTPへ
SSHyのWebSocket部分をPiping Serverを使った純粋なHTTPSでの通信にする。
Piping Serverを使うメリットは、
ポートがグローバルに解放されていない端末に対してもアクセスできること。
SSHサーバー側にはsocatコマンドとcurlコマンドさえあれば、追加でインストールせずにブラウザからSSHできる
まず元のSSHyの通信部分の実装について。
SSHyのWebSocketを使った送信部分
以下がバイト列(バイナリ文字列)の送信部分。
wsがWebSocketのインスタンスで破壊的にsendB64()というメソッドを生やす作りになっている。new SSHyClient.Transport(ws, ...)でwsを受け取りTransport内でws.sendB64()を呼び出す作り。
SSHyのWebSocketを使った受信部分
以下がバイト列(Base64)の受信部分。
WebSocketのonmessageでBase64エンコードされた文字列を受け取ってバイナリ文字列に変換してtransport.parceler.handle()を使って処理してもらうようになっていた。
大事な変更は以下のコードにまとめた。
送信に使うReadableStream は ctrl.enqueue(new Uint8Array(...))をすることで読まれるべきバイト列を作れる。つまり.enqueue(送りたいバイト列)をする。
データの受け取りに使うReadableStreamには.getReader() を使うことができ、const {value, done} = await reader.read();を使うことで読み取ることができる。
code:js
let controller;
// ReadableStreamを作って上記の controller に ctrl を代入
const readable = new ReadableStream({
start(ctrl) {
controller = ctrl;
}
});
function sendBinaryString(binaryString) {
// POSTのリクエストボディのバイト列として送信
controller.enqueue(stringToUint8Array(binaryString));
transport.parceler.transmitData += binaryString.length;
settings.setNetTraffic(transport.parceler.transmitData, false);
}
(async () => {
// 上記のReadableStreamをPOSTする
fetch(${pipingServerUrl}/${path1}, {
method: "POST",
body: readable,
allowHTTP1ForStreamingUpload: true, // Chromeの一時的なプロパティ
});
// データの送信時にsendBinaryStringが呼ばれるようにする
transport = new SSHyClient.Transport(settings, sendBinaryString);
transport.auth.termUsername = termUsername;
transport.auth.termPassword = termPassword;
// GETで受け取る
const res = await fetch(${pipingServerUrl}/${path2});
// レスポンスのReadableStreamのReaderを取得する
const reader = res.body.getReader();
while(true) {
// バイト列のチャンクを読み出す
const {value, done} = await reader.read();
if (done) break;
// バイナリ文字列に変換
const str = uint8ArrayToString(value);
// バイナリ文字列をSSHyに処理してもらう
transport.parceler.handle(str);
}
})();
なるべく既存のコードそのままでHTTPへの通信に切り替えるように変更した。
読み取りと書き込みができるという抽象を作ってそれをnew SSHyClient.Transport()を渡すように変更したりすれば 通信部分のWebSocketとPiping Serverを切り替えたりなど汎化性が増すように思う。
HTTPの可能性
HTTPのリアルタイム通信(HTTPのストリーミング)を中心にHTTPの可能性について。
HTTPの巨大なストリーム
単一のHTTPリクエストで64日間連続で1110TB(1ペタぐらい)Piping Serverを使って転送できている。
HTTPで1110TB転送できている記録(Piping Server)
HTTPは2ヶ月以上通信し続けて1ペタぐらいは送れるプロトコル。
Transfer-Encoding: chunked
以下にチャンクで送信される(Content-Lengthがあらかじめ決まらないストリームなどで使われる)場合はのデータの増加を簡単に調べたものがある。それほど大きく増えていないという結果だった。
HTTPのTransfer-Encoding: chunkedした時のデータの増量は0.0174%程度だった
またHTTP/2であれば、Transfer-Encoding: chunkedされずにバイナリでより効率が良くなるはず。
(HTTP/2では「Transfer-Encoding: chunked」を使ってはいけない)
HTTP/2の多重化
今回のように同じサーバーへのHTTP/2では複数のHTTPリクエストが単一のTCPになれる。
今回のように双方向通信を実現するためにHTTPのリクエストを2つ使う場合でも効率が良い。
これからHTTP/3が普及すればTCPを超え、更なる発展が期待できると思う。
Piping Server での トンネリング
Piping Server を介した双方向パイプによる,任意のネットワークコネクションの確立 - Qiitaで提案されている以下のsocatコマンドを使って SSHサーバーのポート22を転送している。
code:bash
socat 'EXEC:curl -NsS https\://ppng.io/path1!!EXEC:curl -NsST - https\://ppng.io/path2' TCP:127.0.0.1:22
socatとcurlさえあれば良く他にインストールしなくて良いところが魅力。
環境よってはsocatやcurlがなかったり、インストールできない場合もある。
そういう時のためにpiping-tunnelがある。「」で環境にあったバイナリをダウンロードして使える。
使い方は「piping-tunnel server -p 22 path1 path2」静的リンクされたワンバイナリなのでポータブル。
CONNECTメソッドの件
自分の理解しているHTTPのCONNECTメソッドは公開されているポートに対して接続できて、今回のPiping Serverの場合はグローバルにポートが解放されているか関わらず接続できる点が異なると思う。
Piping Serverによるターミナル共有
小原 一哉氏による「ターミナルを共有してブラウザ上で閲覧する - Piping Server経由」がありそれとの比較。
上記は の内容をWebブラウザで実現する内容になっている。今回のSSHyと類似する点は両方ともXterm.jsを利用してターミナル部分を作っている点。違いとしてはSSHをしているためE2E暗号化されており、Piping ServerにはSSHで暗号化された通信が流れる安心感がある点。
TIPS: SSHのキープアライブ
Chromeは60秒間バイト列が送られてこないとHTTPのリクエストが止められることが実験的に分かっている。
そこで以下の設定をSSHサーバー側の/etc/ssh/sshd_configにする。
code:/etc/ssh/sshd_config
# ...
ClientAliveInterval 20
ClientAliveCountMax 3
# ...
これによってSSHサーバー側が20秒置きにデータを送り続けてくれてWebブラウザとの接続が維持される。
無操作状態で4時間ほど放置したが接続は切れていなかった。
おまけ:Piping Serverを使ったアプリケーション
Webでのアプリケーション。Piping Serverと暗号化を組み合わせていることが多い。
E2E暗号化でセキュアなファイル転送
楕円曲線ディフィー・ヘルマン鍵共有を使って安全に鍵を共有してOpenPGPでデータ本体を暗号化する。
公開鍵認証もできるチャット
PEM形式の公開鍵を使って相手の認証したり、メッセージをE2E暗号化できる。
画面共有のE2E暗号化
リアルタイム手書き共有
これもE2E暗号化
これらの仕組みはPiping Serverを通信部分のコアとして利用していて、に手を加えずに実現しているところが重要。すべてのWebアプリケーションが静的にホスティングで済み、リアルタイムなデータ通信にPiping Serverを使うだけ。
そのほかの色々は「」にまとめている。「」のGitHub検索でまとめていないものが見つかる。
おまけ: Rust版のPiping Server
RustでのPiping Server実装も開発している(転送速度が速い)。
GitHub:
他言語だとRust実装はもう1つあり、Go実装がある。また@juner氏によるC#実装もある。
おまけ: VNC
同じような手法でVNCも実現した。
リモートデスクトップ操作をWebブラウザの純粋なHTTP上で実現する(VNC)