SSH接続をWebブラウザの純粋なHTTP上で実現する
HTTPといえばHTML/CSS/JavaScriptや画像などの小さめの限りがあるデータを手に入れるためによく使われている印象がある。REST APIのようなHTTPを使ったAPIでも限りのあるデータがリクエストとレスポンスになる印象が強い。 ここではHTTPでのリアルタイムなデータ通信(HTTPでのストリーミング)が可能であることをSSHの接続ができることを通して広めたい内容になっている。
https://gyazo.com/6934477f2cc13bddb41122b01d1c6a74
vimやtmuxなども普通に動く。
実際のアプリケーション
以下で試せる。
後に述べるfetchのStreaming uploadの機能のために、Chrome 85以上が必要。
使用するには、chrome://flags/にアクセスして以下の「Experimental Web Platform features」をEnabledにする。
https://gyazo.com/561cca07e57978fbc8e5b816111dc672
純粋な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はどちらが先でも良い)
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」。
仕組みは、サーバーホストのポートに流すべきデータをGET /xxxで受け取り、ポートから出て行くデータをPOST /yyyで送信する。WebブラウザでSSHするためには、POST /xxxでネゴシエーションやユーザーの入力をSSHのプロトコルとして送信して、GET /yyyで受け取れるSSHのプロトコルを解釈してターミナルの画面を構成すれば良い。
Webブラウザで完結するSSHクライアント実装
Piping Serverとホスティング以外にサーバーを用意しない方針のため、SSHを話すプロキシサーバーは作らない。そのためWebブラウザからSSHを話せる必要がある。 だがソースを読んだ感じだと、両者ともsshクライアントの実装がサーバー側になっている。webssh は Pythonの paramiko が使われ、webssh2はnpmのssh2(Node.js向け)が使われている。つまりこれらのライブラリではWebブラウザが直接SSHを話さない設計になっているようだった。 SSHy
https://gh-card.dev/repos/stuicey/SSHy.svg https://github.com/stuicey/SSHy
ずっとWebブラウザでのJavaScriptでのSSHクライアント実装を探していて、求めていたものだった。 実装の透明性という利点
WebブラウザのJavaScriptでSSHクライアントの実装が完結する大きな利点は、SSHクライアントの実装が透明であることだと考えている。つまり、
中継するプロキシサーバーを信頼できなくても構わない。
websshやwebssh2のような実装ではプロキシサーバーにパスワードなどの情報が渡るはずである
ユーザーがやろうと思えばSSHの実装を検証できる。
などが透明性の良さ。
JavaScriptでのHTTPのストリーミング
ブラウザでのJavaScriptでの話。
HTTPでのリアルタイムな通信(HTTPのストリミーング)はシンプル。
受け取るときは以下で出来る。(そこそこ前からある機能)
code:js
const readableStream = res.body;
以下でreadableStreamが送信ができる。
code:js
method: "POST",
body: readableStream,
});
SSHyをWebSocketから純粋なHTTPへ
ポートがグローバルに解放されていない端末に対してもアクセスできること。
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は2ヶ月以上通信し続けて1ペタぐらいは送れるプロトコル。
Transfer-Encoding: chunked
以下にチャンクで送信される(Content-Lengthがあらかじめ決まらないストリームなどで使われる)場合はのデータの増加を簡単に調べたものがある。それほど大きく増えていないという結果だった。
HTTP/2の多重化
今回のように同じサーバーへのHTTP/2では複数のHTTPリクエストが単一のTCPになれる。 今回のように双方向通信を実現するためにHTTPのリクエストを2つ使う場合でも効率が良い。 Piping Server での トンネリング
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 server -p 22 path1 path2」静的リンクされたワンバイナリなのでポータブル。
CONNECTメソッドの件
自分の理解しているHTTPのCONNECTメソッドは公開されているポートに対して接続できて、今回の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を使ったアプリケーション
E2E暗号化でセキュアなファイル転送
公開鍵認証もできるチャット
PEM形式の公開鍵を使って相手の認証したり、メッセージをE2E暗号化できる。
画面共有のE2E暗号化
リアルタイム手書き共有
これもE2E暗号化
そのほかの色々は「」にまとめている。「」のGitHub検索でまとめていないものが見つかる。 おまけ: Rust版のPiping Server
おまけ: VNC