本文へスキップ

node-pty xterm.js websocket を利用したブラウザで動くShellの作成

14

概要

node-pty xterm.js websocket を利用したブラウザで動くShellの作成をしてみました。

利用するモジュールの説明

node-pty

microsoft/node-pty: Fork pseudoterminals in Node.JS

node-ptyは、Node.jsでターミナルエミュレータを実装するためのモジュールです。

OSのテキストベースの端末エミュレータ(例えば、xtermやgnome-terminalなど)をラップして、Node.jsから呼び出すことができるようにします。 そのため、Node.jsで書かれたアプリケーションからターミナルを実行したり、ターミナルからのデータを読み取ったりすることができます。

node-ptyは、コマンドを実行したり、ターミナルを操作したりするためのAPIを提供しています。 また、node-ptyはオープンソースのモジュールであり、GitHub上で開発されています。

xterm.js

xtermjs/xterm.js: A terminal for the web

xterm.jsは、webブラウザ上で動作するターミナルエミュレータのライブラリです。 ターミナルエミュレータは、コンピュータでコマンドを入力し、その結果を表示するためのテキストベースの画面を提供するものです。 JavaScriptで書かれており、webブラウザ上で動作するようになっています。 xterm.jsを使用すると、webアプリケーションやサイトにターミナルエミュレータの機能を組み込むことができます。

ws

websockets/ws: Simple to use, blazing fast and thoroughly tested WebSocket client and server for Node.js

"ws"は、WebSocketを実装するためのJavaScriptライブラリです。

WebSocketは、クライアントとサーバー間でリアルタイムでデータをやり取りするためのプロトコルです。WebSocketを使用すると、サーバーからのイベントを受信したり、クライアントからのデータを送信したりすることができます。

"ws"ライブラリは、WebSocketを使用するためのAPIを提供しており、Node.jsでWebSocketを扱うためによく使われます。

大まかな構成

まず、node-ptyを使用してサーバーサイドでターミナルを起動し、xterm.jsを使用してクライアント側でターミナルのようなインターフェースを表示します。

次に、WebSocketを使用して、サーバーとクライアント間でデータをやり取りするようにします。 これにより、クライアントからサーバーへのコマンドを送信したり、サーバーからのターミナルの出力をクライアントに表示することができます。

具体的には、まずサーバーサイドでnode-ptyを使用してターミナルを起動します。 そして、WebSocketサーバーを起動します。次に、クライアント側でxterm.jsを使用してターミナルのようなインターフェースを表示し、WebSocketを使用してサーバーと通信するようにします。 クライアントからのコマンドを受け取ると、サーバーはnode-ptyを使用してそのコマンドを実行し、その結果をクライアントに送信します。 これを繰り返すことで、ブラウザ上で動作するシェルを実装することができます。

ソース

package.json

{
  "scripts": {
    "dev": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "node-pty": "^0.10.1",
    "ws": "^8.11.0",
    "xterm": "^5.0.0",
    "xterm-addon-fit": "^0.6.0",
    "xterm-addon-ligatures": "^0.6.0",
    "xterm-addon-search": "^0.10.0",
    "xterm-addon-serialize": "^0.8.0",
    "xterm-addon-unicode11": "^0.4.0",
    "xterm-addon-web-links": "^0.7.0"
  }
}

index.html

<html>
  <head>
    <meta charset="utf-8">
    <style>
      html {
        height: 100%;
      }
      body {
        height: 100%;
        margin: 0;
      }
      .fullheight {
        height: 100%;
        background: black;
      }
    </style>
    <link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
    <script type="text/javascript" src="node_modules/xterm/lib/xterm.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-fit/lib/xterm-addon-fit.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-ligatures/lib/xterm-addon-ligatures.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-search/lib/xterm-addon-search.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js" charset="utf-8"></script>
    <script type="text/javascript" src="node_modules/xterm-addon-serialize/lib/xterm-addon-serialize.js" charset="utf-8"></script>
  </head>
  <body>
    <div class="fullheight" id="terminal"></div>
    <script type="text/javascript" src="cli.js" charset="utf-8"></script>
  </body>
</html>

index.js

const express = require('express');
const app = express();
const server = require('http').Server(app);
const nodePty = require('node-pty');
const WebSocket = require('ws');

app.use('/', express.static('.'));
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
  let pty = nodePty.spawn('bash', ['--login'], {
    name: 'xterm-color',
    cols: 80,
    rows: 24,
    cwd: process.env.HOME,
    env: process.env,
  });
  pty.onData((data) => {
    ws.send(JSON.stringify({ output: data }));
  });
  ws.on('message', (message) => {
    console.log('received: %s', message);
    m = JSON.parse(message);
    if (m.input) {
      pty.write(m.input);
    } else if (m.resize) {
      pty.resize(m.resize[0], m.resize[1]);
    }
  });
});

server.listen(process.env.PORT || 8999, () => {
  console.log(`Server started on port ${server.address().port} :)`);
});

cli.js

const term = new Terminal({
  cols: 80,
  rows: 24,
  allowProposedApi: true,
});
term.open(document.getElementById('terminal'));

// addons
const fitAddon = new FitAddon.FitAddon();
// const ligaturesAddon = new LigaturesAddon.LigaturesAddon();
const searchAddon = new SearchAddon.SearchAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
const serializeAddon = new SerializeAddon.SerializeAddon();

[
  fitAddon,
  // ligaturesAddon,
  searchAddon,
  webLinksAddon,
  unicode11Addon,
  serializeAddon,
].map((e) => term.loadAddon(e));

term.unicode.activeVersion = '11';

const ws = new WebSocket(`ws://${location.hostname}:8999`);

ws.addEventListener('open', () => {
  console.info('WebSocket connected');
});
ws.addEventListener('message', (event) => {
  console.debug('Message from server ', event.data);
  try {
    let output = JSON.parse(event.data);
    term.write(output.output, () => {
      console.log(serializeAddon.serialize());
    });
  } catch (e) {
    console.error(e);
  }
});

term.onData((data) => ws.send(JSON.stringify({ input: data })));

window.addEventListener('resize', () => {
  fitAddon.fit();
});

fitAddon.fit();

term.onResize((size) => {
  console.debug('resize');
  const resizer = JSON.stringify({ resizer: [size.cols, size.rows] });
  ws.send(resizer);
});

JS版のソースです。

https://github.com/s-yoshiki/node-websh/tree/8528ff6d61a2100afefba662584b3d7c306d7408

TypeScriptで書き直す

TypeScriptで書き直しました。

s-yoshiki/node-websh: node-pty xterm.js websocket を利用したブラウザで動くShell

参考にしたサイト

dews/webssh: xterm + node-pty + websocket

【Node.js + Express】WebSocketを使ってみる( + 全クライアントに一斉送信) - とある科学の備忘録

xterm.jsでキーボード入力を受け付ける方法 - haku-maiのブログ

Node.js Stream を使いこなす - Qiita

公開前に必ず行うセキュリティ対策

この構成はブラウザからOSのシェルへ入力できるため、サンプルのままインターネットへ公開してはいけません。node-pty で起動したプロセスは親Node.jsプロセスと同じ権限を持ちます。

  • WebSocket接続前に認証・認可し、セッションごとに利用者を特定する
  • wss:// を使い、Originも検証する
  • rootで起動せず、専用の低権限ユーザーとコンテナ・sandboxを使う
  • 接続数、アイドル時間、実行時間、入出力量に上限を設ける
  • 切断時にPTYと子プロセスを確実に終了する
  • 端末I/O、タイトル、リンクを信頼せず、innerHTML へ渡さない
  • 実行可能コマンドや接続先を用途に応じて制限する
  • 操作ログを機密情報を除外した上で監査可能にする

xterm.js公式は、デモとattach addonをWebSocket実装へそのまま流用しないよう警告しています。端末ページに読み込む第三者スクリプトもキーストロークを読めるため、依存関係とCSPを厳格に管理します。

切断処理の例

const stop = () => {
  if (!ptyProcess) return;
  ptyProcess.kill();
  ptyProcess = undefined;
};

ws.on('close', stop);
ws.on('error', stop);

const idleTimer = setTimeout(() => {
  ws.close(1000, 'idle timeout');
  stop();
}, 15 * 60 * 1000);

実際にはタイマー更新、二重終了の防止、子プロセスツリーの扱いも含めて設計してください。

公式資料

最新の記事

著者について

404

s-yoshiki

ただの備忘録です。

※外部送信に関する公表事項