はじめに
こんにちは。久々にプライベートの開発を行ったので、今回はDjangoアプリ上でxterm.jsとWebSocketを利用してWebブラウザでターミナル操作を擬似的
に行える方法を共有したいと思います。
因みに、WebSocketについては、以前RailsアプリでActionCableの開発を行った時にまとめてあるのでWebSocketについて詳しく知りたい方はこちらの記事を参考にして下さい→【Rails】websocket_railsで開発していたチャットをActionCableにリプレース
なぜターミナル操作が必要なのか
現在、jaistingと言うコンテナホスティング用のWebアプリケーションを開発中です。このアプリケーションでは、
- コンテナをWebブラウザ上で作成できる
- コンテナとホストとの疎通性・及びホストの外への疎通性が取れるようにIPFW + NATの設定がWebブラウザ上で行える
- 準仮想化機構であるjailの起動・停止ができる
の3つがあります。この後は、コンテナへPort Forwardingを有効にしてリモートログインをすれば自由にコンテナを取り扱うことができます。
しかし、それを行うために必要な操作・設定をどこかで行う必要があります。現在は、ホストにSSH接続してchrootやchdirで直接jail上の操作を行うことができますが、それでは一々ホストに公開鍵を登録しなければならないので面倒です。
そこで、ブラウザ上で擬似的なターミナルを表示させて、WebSocket経由でコンテナで実行したいコマンドを渡しその結果を返せるようにします。これにより、エンドユーザの操作からは、ターミナル上でコンテナの操作をしているように見せかける
仕組みになると推察しています。
今回作る機能を組み合わせたJaistingのポンチ絵はこちら→
今回はこのコネクションの中で操作する部分を作っていきたいと思います。
Channelsとは
Djangoで非同期的な通信、またHTTPだけでなくWebSocketは勿論の事、MQTT、chatbotのような通信も扱えるツールです。
Channelsインストール
以下のコマンド・設定はhttps://channels.readthedocs.io/en/latest/installation.htmlより引用
% pip install channels
INSTALLED_APPS = (
'channels',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
)
ASGI_APPLICATION = "jaisting.routing.application"
from channels.routing import ProtocolTypeRouter
application = ProtocolTypeRouter({
# Empty for now (http->django views is added by default)
})
コンテナの操作を行えるConsumerを作る
サーバサイド側でWebSocketによる接続、データ送信等のリクエストが送信された場合、応答を返すためのコードをConsumerとして作ります。
from channels.generic.websocket import WebsocketConsumer
import json
import libioc
class VNCConsumer(WebsocketConsumer):
def connect(self):
self.accept()
def disconnect(self, close_code):
pass
def receive(self, text_data):
data_json = json.loads(text_data)
try:
pdb.set_trace()
jail = libioc.Jail(data_json["jail_name"])
response = jail.exec(data_json["message"].split(" "))
except (libioc.errors.JailNotFound):
self.send(text_data='%s not found' % data_json["jail_name"])
self.send(text_data=response[0])
本当はVNCではないですが、やりたい事は似たような事なので、このような名前にしています。
connect
やreceive
メソッドでコネクション確立要求やデータ送信が行われたときにacceptでコネクションを受けつけ、受信したデータからコマンド実行を行い、レスポンスをクライアントにsendして返すようにしています。
WebSocketへのパスの指定は以下のようにします。
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import jails.routing
application = ProtocolTypeRouter({
# (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter(
jails.routing.websocket_urlpatterns
)
),
})
from channels.routing import ProtocolTypeRouter
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'/websocket', consumers.VNCConsumer)
]
クライアントサイドで疑似ターミナルを立ち上げコンテナ操作をしているように見せかける
クライアント側はxterm.js
と呼ばれるフロントエンドでターミナルを立ち上げることができるOSSが便利です。また、こちらのxterm.jsはアドオンツールとしてxterm-addon-attach
と呼ばれるxterm.jsにアタッチすることでWebSocketによる通信をサポートしてくれます。
後は、コネクションの確立、データ送信のクライアントサイドのコードを書くこと及びxterm.jsで利用できるメソッドを利用してターミナルっぽい動きをさせます。以下はクライアントサイドの全コードとなります。
<template>
<div id="terminal"></div>
</template>
<script>
import axios from 'axios'
import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach';
axios.defaults.xsrfCookieName = 'csrftoken';
axios.defaults.xsrfHeaderName = 'X-CSRFToken';
export default {
name: 'Jails-Connect',
mounted: function () {
const term = new Terminal();
const socket = new WebSocket("ws://localhost/jails/websocket");
const attachAddon = new AttachAddon(socket);
socket.addEventListener( "message", (response) => {
term.write("$ " + response.data + "\r\n");
term.write("$ ");
});
let current_line = "";
let cursor = 0;
const jail_name = document.getElementById("jail_name").value;
term.open(document.getElementById('terminal'));
term.write("$ ");
term.onKey((data) => {
switch(data.domEvent.key) {
case "Enter":
term.write("\r\n");
if(current_line) {
socket.send(JSON.stringify({
'message': current_line,
"jail_name": jail_name
}));
} else {
term.write("\r\n");
term.write("$ ");
}
cursor = 0;
current_line = "";
break;
case "ArrowUp":
case "ArrowDown":
break;
case "ArrowLeft":
if(cursor > 0) {
cursor -= 1;
term.write(data.key);
}
break;
case "Backspace":
if(cursor > 0) {
cursor -= 1;
current_line = current_line.slice(0, -1);
term.write('\b \b');
}
break;
default:
if (cursor < 120) {
cursor += 1;
current_line += data.key;
term.write(data.key);
}
break;
}
});
}
}
</script>
<style scoped>
.vue-loading {
color: #0DC5C1;
position: absolute;
top: 50%;
left: 50%;
margin-left: -75x;
margin-top: 150px;
overflow: auto;
}
</style>
まとめ
これだけだと、ifconfigのような単発な結果は得られるのですが、vimやら、pingと言ったコンテナとのコネクションをずっと確立させなければならないものはまだできていません。
とりあえず、本日はここまで。
そろそろ、リファクタリングしたいので、誰か協力者募集中です汗
参考文献
- https://channels.readthedocs.io/en/latest/introduction.html
- https://medium.com/swlh/local-echo-xterm-js-5210f062377e
- https://xtermjs.org/docs/
- https://stackoverflow.com/questions/56828930/how-to-remove-the-last-line-in-xterm-js
- http://www.denzow.me/entry/2018/03/27/002350
- https://channels.readthedocs.io/en/latest/tutorial/part_1.html
- https://channels.readthedocs.io/en/latest/tutorial/part_2.html
- https://stackoverflow.com/questions/54189424/xterm-js-getting-current-line-text
シリーズ
Part 7 ←