リモートデスクトップ操作をWebブラウザの純粋なHTTP上で実現する(VNC)
純粋なHTTPSストリーミング
素のHTTPの可能性を広める第三弾目。
以下がデモ。手前のウインドウがGoogle Chromeで遠隔操作するVNCクライアント側、後ろのウインドウが遠隔操作されるVNCサーバー側。VNCの映像や操作の通信が純粋なHTTPS上に流れている。 https://gyazo.com/4f73fd3c3da40a1f0171a2df66d4bb89
(VMで動かしていてもウインドウの移動やスクロールなど結構スムーズに操作できている。)
実際のアプリケーション
以下で試せる。
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
データはテキスト・バイナリ問わず無限に流せられる。
双方向リアルタイム通信
code:bash
socat 'EXEC:curl -NsS https\://ppng.io/aaa!!EXEC:curl -NsST - https\://ppng.io/bbb' TCP:127.0.0.1:5900
以下はWebブラウザ(VNCクライアント)側での双方通信の様子。
注目すべきは開発者ツールのNetworkタブ。2つはHTTPSの通信がある。GETが上のv86で、POSTが下の7vkになっている。画面が操作され画面のダウンロードが発生する時に上のv86のSizeが増えていることが分かる。
https://gyazo.com/cc9a663b3f29bad3059e21a7819f1f26
POST側の7vkもデータを送り続けている。表示上はpendingとなる。
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」。
Webブラウザ上で動くVNCクライアント
今回もVNCクライアント側を静的ホスティングしたかったため、ブラウザ上でVNCのRFBプロトコルを話すプロジェクトを探した。構成としては静的ホスティング + Piping ServerだけでVNCしたい。 https://gh-card.dev/repos/novnc/noVNC.svg https://github.com/novnc/noVNC
映像のスムーズさも元のプロジェクトのおかげ。
現在も活発に開発されているように見える。
(noVNCという名前がなぜ否定ぽい"no"含まれているのかは良くわからない。) 描画はHTML5のcanvasが使われている。
WebSocketから純粋なHTTPへ
ポートがグローバルに解放されていない端末に対してもアクセスできる。
送信部分
noVNCから送信する部分はcore/websock.jsのflush()の以下のsend()だけだった。 code:core/websock.js
...
this._websocket.send(this._encodeMessage());
受信部分
noVNCで受信する部分は以下のonmessageの部分だけだった。 code:core/websock.js
...
this._websocket.onmessage = this._recvMessage.bind(this);
変更点
今回の変更で重要な送信と受信が一箇所にまとまっていて通信を他の仕組みに変更しやすい作りになっていた。
以下がWebSocketからHTTPでのリアルタイム通信に変更した時の差分。
変更したことは、
送信側ではReadableStreamのControllerにenqueue()データを送るようにしたこと、
code:送信側.js
...
const message = new Uint8Array(this._encodeMessage());
this._readableStreamController.enqueue(message);
受信側ではread()したバイナリをthis._recvMessage()にハンドリングしてもらうようにしたこと。
code:受信側.js
...
while (true) {
const {value, done} = await reader.read();
if (done) break;
this._recvMessage(value);
}
基本的にこういったシンプルな変更で原理的にはあらゆるプロトコルをWebブラウザの純粋なHTTPS上に乗せられる。
ハマったこと
WebSocketのsend()とReadableStreamのenqueue()の違いに起因するもの。
気がつけば簡単で恥ずかしいハマり方をしたのでノウハウとして紹介。
以下にWebSocketでの送信とReadableStreamの送信を比較しやすいように並べて書く。 code:WebSocketでの送信.js
this._websocket.send(this._encodeMessage());
code:ReadableStreamでの送信.js
this._readableStreamController.enqueue(this._encodeMessage()); // これだと意図した通りに動かない
違いはthis._websocket.send(とthis._readableStreamController.enqueue(だけだということが分かる。
送りたいデータは同じでthis._encodeMessage(): Uint8Array。
詳しい型は以下の通り。
this._encodeMessage(): Uin8Array
原因と解決策
コメントにも残した通り、ReadableStreamの方はこのままでは意図した通りに動かなかった。
this._encodeMessage()はUint8Arrayを返すが、返されるUint8Arrayの内容は破壊的に変更されるようにnoVNCでは出来ている。その影響で.enqueue()したUint8Arrayが破壊的に変更されてしまう。そのため意図した通りのRFBプロトコルを話せなくなってしまっていた。解決策は簡単でnew Uint8Array(this._encodeMessage())でコピーを作成すること。 WebSocketのsend()の場合でうまく行くのは引数のUint8Arrayが内部でコピーされるからだと思われる。ただし以下の仕様を眺めてみたが明示的にコピーされるという風に書かれているようには見えない。 MDNを見るとキューに入れるというところから暗黙的にコピーが発生していそうな感じが伝わってくる。
WebSocket.send() は WebSocket 接続を介してサーバーに送るために指定されたデータを、格納するために必要なバイト数だけ bufferedAmount の値を増加させながら、キューに入れるメソッドです。
Uint8Arrayをコピーしなくてもパスワードの認証と途中まで画面が表示されたため、ある条件で途中でバイナリが意図してないものに変更された可能性を考え別の方向を疑ってしまった。WebSocketのプロキシで特別なことをしていないかやちょっとRFBプロトコルを学習して送受信されるバイト列を読んだりプロキシのコードを最小限にしたり違う方向を調査してしまった。その過程でRFBプロトコルを大雑把に知れたのは良かった。 おまけ: Ubuntu 20.04でのVNC
Settings > Sharingを使うことでリモート操作ができる機能が標準であるのでそれを利用した。
https://gyazo.com/0aa9e198310c68602cee64ed37252196
ただしnoVNCは暗号化に対応していないようで、このまま接続するとブラウザのコンソールに「Failed when connecting: Unsupported security types (types: 18)」というエラーが出る。対処法は以下のコマンドで暗号化を無効化すること。 code:bash
gsettings set org.gnome.Vino require-encryption false
ただし今回のPiping Serverを使った方法ではHTTPSで暗号化され、ポート解放もせずに接続できるためファイアウォールで5900を守れば安心して使えると思う。任意のプロトコルをE2E暗号化して通信するとかはまたの機会でやってみるかもしれない。