変奏現実

パソコンやMMORPGのことなどを思いつくまま・・・記載されている会社名・製品名・システム名などは、各社の商標、または登録商標です。

この画面は、簡易表示です

xterm.js

[xterm.js]ssh接続その4

段々複雑になってきたので

xterm.jsの画面からは

  1. 画面からWebSocketで何か送信する
    • {
      • ssh : {
        • logon : {
          • username : xxxx
          • etc.
    • }…}
  2. ホスト側で受信
  3. JSONデータをアドオンのエイリアス(ssh)で配分する
    • { ssh : ・・・
    • sshエイリアスなアドオンのjsonRequestを呼び出す
  4. アドオンは受け取ったJSONデータから機能を実行する
    • { logon : ・・・
    • 処理名(logon)を読み取ってlogon処理を実行する
    • logon処理
      • { username : xxxx, …}
  5. 処理名が何か出力したらWebSocketで返信する

にすると後付けが楽な気がしたので、NodeJsで動く部分をアドオン化してみたら更に複雑になった。

├── package.json
├── package-lock.json
├── index.js
├── lock_file.js
├── tree.txt
├── web_socket_entity.js
└── addon
  ├── package.json
  ├── base_addon.js
  ├── js_yaml
  │├── package.json
  │└── js_yaml.js
  └── ssh_client
    ├── package.json
    ├── package-lock.json
    └── ssh_client.js

アドオンフォルダ(./addon)にプロジェクトごとコピーする方式。

AddonManagerのsetupで、アドオンフォルダ(./addon)の中のフォルダにある

package.jsonのmainかexportをからモジュールのjsファイルを見つけて

エイリアス付きでtypeListsにリストアップするダケなのにとっても長い。

/**
 * アドオンマネジャクラス
 */
export class AddonManager extends BaseAddon {
 ・・・省略・・・
  /**
   * リスト
   */
  typeLists = {};
 ・・・省略・・・
  /**
   * コンストラクタ
   */
  constructor() {
    super();
  };
  /**
   * ./addonディレクトリィのパッケージを検索
   * @param (*) pathAddonsDir
   */
  setup = (pathAddonsDir, allAddonInfo) => {
    this.allAddonInfo = allAddonInfo;
    return new Promise((resolve, reject) => {
      fs.readdir(pathAddonsDir, { encoding: 'utf-8', withFileTypes: true, recursive: false },
        (err, dirents) => {
          /**
           * error
           */
          if (err) {
            console.error(err);
            reject(err);
            return;
          }
          /**
           * ディレクトリィのみに絞込む
           */
          dirents = dirents.filter((d) => d.isDirectory());
          /**
           * とりあえず配列分ループ
           */
          const arP = dirents.map((dirent) => {
            return new Promise((resolve, reject) => {
              const addonPath = `${pathAddonsDir}/${dirent.name}`;
              console.log(addonPath);
              // dirent配下のpackage.jsonを読む
              const packageJsonPathname = `${addonPath}/package.json`;
              const packageJsonText = fs.readFileSync(packageJsonPathname);
              const packageJson = JSON.parse(packageJsonText);
              const mainFile = packageJson.main || packageJson.exports;
              if (mainFile !== undefined) {
                const mainFilePathname = `${addonPath}/${mainFile}`;
                import(mainFilePathname)
                  .then((module) => {
                    // 動的に読み込まれたモジュール
                    const addonModule = new module.default;
                    // アドオンリストに追加
                    this.setAddonList(addonModule.addonAlias, addonModule);
                    resolve(true);
                  })
                  .catch((ex) => {
                    console.error(ex);
                    reject(ex);
                  });
              } else {
                console.error(`addonSetup : ${packageJsonPathname} not found main or exports`);
              }
            });
          });
          Promise.all(arP)
            .then((values) => {
              resolve(true);
            });
        }
        // end of for (const dirent of dirents)
      );
    });
  };
 ・・・省略・・・
};

アドオンを呼び出す時は

AddonManager::getAddonObject({アドオンのエイリアス})でオブジェクトを取得

  /**
   * アドオン・オブジェクトを取得
   * @param {*} alias 
   * @returns addon
   */
  getAddonObject = (alias) => {
    const addonInfo = this.typeLists['addon'][alias];
    if (addonInfo) {
      const object = new addonInfo.constructor(this.allAddonInfo[alias]);
      return object;
    } else {
      return undefined;
    }
  };

画面からの要求を{アドオンのオブジェクト}::jsonRequest(WebSocket, json)から

アドオンオブジェクトを作成し実行させている。

  /**
   * JSONリクエスト処理
   * @param {*} webSocketEntity 
   * @param {*} json 
   * @param {*} addonObjectList 
   */
  jsonRequest = (webSocketEntity, json, addonObjectList) => {
    const addonModules = this.typeLists['addon'];
    for (const alias in json) {
      const addon = addonObjectList[alias] ?? this.getAddonObject(alias);
      if (!addonObjectList[alias]) { addonObjectList[alias] = addon; }
      if (addon) {
        try {
          addon.request(webSocketEntity, json[alias]);
        } catch (ex) {
          console.log(`AddonManager::jsonRequest : unknown addon alias  '${alias}'`);
        }
      } else {
        console.log(`AddonManager::jsonRequest : unknown addon alias  '${alias}'`);
      }
    }
  };

レスポンスはアドオンオブジェクトにWebSocket宛に送信してもらった。

  /**
   * クライアントに返信する処理
   * @param {string} type 
   * @param {string} commandName 
   * @param {any} data 
   */
  sendClient = (type, commandName, data) => {
    //console.log(`${commandName} : ${data}`);
    const responce = {};
    responce[type] = data;
    const blob = new Blob([JSON.stringify(responce)], { type: "application/json" });
    this.connect.send(blob);
  };

やっと画面側もtype毎に処理を分けないといけない事に気が付く。

めんど



[xterm.js]ssh接続その3

WebSocketをxtermのアドオン@xterm/addon-attachに渡してるけど、このままではログをちょっとみたいとかできない。

@xterm/addon-attachから送信されるデータがいつもUint8Arrayなので、挟むコマンドはテキストで送ればいいのかと思ったら、クライアント側でstring, ArrayBuffer, Blobのどれをsendしようが、サーバ側にはUint8Arrayとして引き渡されていたので、サーバ側はいつもJSONデータが渡ってくる前提でコード。

/**
 * WebSocketクライアント(xterm.js)からメッセージ受信時の処理
 */
ws.on('message', async (event) => {
  // JSON.stringify()でテキストで送信しているハズ
  const textJson = await new Response(event).text();
  // JSONに成~れ!
  try {
    const json = JSON.parse(textJson);
    // JSONに成った!
    // sshかな?
    if (json.ssh) {
      if (typeof json.ssh === 'string') {
        console.log(`resv ssh text : '${json.ssh}'`);
        stream.write(json.ssh);
      } else if (json.ssh instanceof Uint8Array) {
        // いつものUint8Array
        const text = new TextDecoder().decode(json.ssh);
        console.log(`resv ssh binary : '${text}'`);
        stream.write(json.ssh);
      } else {
        console.log(`resv ssh unknown type[${typeof json.ssh}] : '${json.ssh}'`);
      }
    } else if (json.{その他1}) {
      // {その他1}かな?
      const resultText = {その他1}(json.{その他1});
      console.log(`{その他1}('${json.{その他1}}')\n='${resultText}'`);
      // 結果をJSONに置き換えて
      const responce = {
        {その他1}: resultText,
      };
      // 送信
      const blob = new Blob([JSON.stringify(responce)], { type: "application/json" });
      ws.send(blob);
    } else if (json.{その他2}) {
      ・・・省略・・・
    } else if (json.{その他n}) {
      ・・・ほぼ{その他1}と似た感じ
    } else  {
      // しらないコマンド
    }
  } catch (ex) {
    // 私はJSONに成れないのか!
    console.log(`resv not json's text : ex : '${ex}'`);
  }
});

クライアント側は、WebSocketのmessageメソッドに

webSocket.addEventListener("message", async (event) => {
 ・・・
  preventDefault();
}, { passive: false });

しても、xtermjs画面にデータを表示してしまう。

仕方が無い。

@xterm/addon-attachを外して・・・

自前でWebSocketを送受信する。(前途多難そう

/**
 * WebSocketの処理 ***************************
 */
/**
 * WebSocketのmessageイベント処理
 */
webSocket.addEventListener("message", async (event) => {
  // テキストにする
  const textJson = await new Response(event.data).text();
  // JSONに成~れ
  try {
    const json = JSON.parse(textJson);
    // 内容で分岐
    if (json.ssh) {
      const s = json.ssh;
      terminal.write("string" == typeof s ? s : new Uint8Array(s));
    } else if (json.{その他1}) {
      //  {その他1}のレスポンス
      console.log(`{その他1}='${json.{その他1}}'`);
    } else if (json.{その他2}) {
・・・
    } else if (json.{その他n}) {
      //  {その他n}のレスポンス
      console.log(`{その他n}='${json.{その他n}}'`);
    } else {
       // 知らないコマンド
    }
  } catch (ex) {
    // JSONに成れなかった
  }
});
/**
 * WebSocketのcloseイベント処理
 */
webSocket.addEventListener("close", ((event) => {
  terminal.write('*** disconnection ***');
  console.log('*** disconnection ***');
}));
/**
 * WebSocketのerrorイベント処理
 */
webSocket.addEventListener("error", ((event) => {
  terminal.write('*** socket error ***');
  console.error(`socket error : ${event}`);
}));
/**
 * Terminalのイベント処理
 */
/**
 * Terminalのdataイベント処理
 */
terminal.onData((event) => {
  // sshに送信するJSONに変換
  const msg = {
    ssh: event,
  };
  // テキストに展開しBlobで送信
  const blob = new Blob([JSON.stringify(msg)], { type: "application/json" });
  webSocket.send(blob);
});
/**
 * Terminalのbinaryイベント処理
 */
terminal.onBinary((event) => {
// 呼ばれてないので省略
});

@xterm/addon-attachを代行する処理は、

WebSocketのmessageの処理は長いけど(独自コードが多い

xtermjsのデータを送信するのは短くてよかった。

本当は色々チェックが必要なんだろうけど。(ま、いいか



[xterm.js]ssh2接続

ブラウザとシェルはWebSocketで非同期に相互に(つまり自分勝手)に通信する。

node.jsでspawnすればシェルと繋がるけど、改行入力で1行入力(readline)っぽくシェルがやってくれると思ってたら、改行しても応答無しstdinストリームを閉じまで入力したコマンドを実行する気配が無かった、BackSpaceもそのまま渡ってしまう・・・

どうやらsshサービスが頑張ってる様だ。

node.jsからシェル起動は諦めて、ssh2サービスに繋ぐことにする。

1.WebSocketサーバーの作成

# mkdir testServer
# cd testServer
# npm init
・・・・・
# npm install ssh2            ※SSH2パッケージ
# npm install ws              ※WebSocketパッケージ
const WebSocket = require('ws');
const { sshTerm } = require('./sshTerm.js');
// SSH接続情報
const sshInfo = {
user      : '{SSHのログイン・ユーザ名}',
password  : '{SSHのログイン・パスワード}' {または} privateKey: require('fs').readFileSync('/PATH/id_rsa') ,
ipaddress : '{SSHのホストIPアドレス}',
sshport   : {SSHのポート番号},
wsport    : {WebSocketのポート番号},
httpport  : {apacheのポート番号},
httpath   : '{urlのパス}',
};
// ポート${sshInfo.wsport}でWebSocketサーバーを作成
const wss = new WebSocket.Server({ port: sshInfo.wsport });
// 案内文
console.log(`ssh ready, URL http://${sshInfo.ipaddress}:${sshInfo.httpport}/${sshInfo.httpath}`);
// WebSocketクライアント(xterm.js)接続時の処理
wss.on('connection', function connection(ws) {
  const env = process.env;
  console.log('connection WebSocket client.');
   const bash = sshTerm(ws, sshInfo);
});

passwordかprivateKeyかprivateKeyPathのいづれかを指定できる。

const { Client } = require('ssh2');
// SSH接続を確立
const sshTerm = async (ws, sshInfo) => {
  const commandName = 'bash';
  const conn = new Client();
  conn.on('ready', () => {
    // ssh2接続準備完了
    console.log('Client :: ready');
    // shell対応
    conn.shell((err, stream) => {
      if (err) throw err;
      stream.on('close', () => {
        // エラった時
        if (code !== 0) {
          console.log(`${commandName} process exited with code : ${code}.`);
        } else {
          console.log(`${commandName} process exited.`);
        }
        ws.send(`disconnected.`);
        conn.end();
      }).on('data', (data) => {
        // ssh2からデータが送られた時の処理
        console.log(`${commandName} : ${data}`);
        data = data.toString().replaceAll('\n', '\r\n');
        ws.send(data);
      });      
      // WebSocketクライアント(xterm.js)からメッセージ受信時の処理
      ws.on('message', async (message) => {
        console.log('受信したメッセージ: %s', message);
        stream.write(message);
      });      
      // WebSocketクライアント(xterm.js)クライアントから切断された時の処理
      ws.on('close', function close() {
        console.log('クライアントとの接続が切断');
        stream.end();
      });
      // Ctrl+Cで止められた時の処理      
      process.on('SIGHUP', ()=> {
        console.log('Got SIGHUP. ');
        stream.end();
        console.log('クライアントの接続をパージ');
        ws.close();
     });
     // 以上
    });
  }).connect({
    host: sshInfo.ipaddress,
    port: sshInfo.sshport,
    username: sshInfo.user,
    password: sshInfo.password {または} privateKey: sshInfo.privateKey,
  });
};
exports.sshTerm = sshTerm;
  • privateKey
    • ‘~/.ssh/id_rsa` の様な ‘~’ を使うとopenエラー。
      • 代用はprocess.env[‘HOME’]
  • privateKeyPath
    • エラってしまう。

testServerプロジェクトの雰囲気

  • testServer
    • index.html
    • sshTerm.js
      • node_modules
        • ssh2
        • ws

ここで、

# node index.js

でサーバー側の準備は終わり。

2.WebSocketクライアントの作成

クライアントはapacheのhtmlの下にtestディレクトリィを作りxtermのアドオン等を追加する。

# cd /var/www/html
# mkdir test
# cd test
# npm init
・・・
# npm install @xterm/xterm
# npm install @xterm/addon-web-links
# npm install @xterm/addon-attach
# npm install @xterm/addon-clipboard
# npm install @xterm/addon-fit
# npm install @xterm/addon-image
# npm install @xterm/addon-web-links
# npm install @xterm/addon-webgl

必要なのは、node_modules/@xterm下の cssファイル、jsファイル、mapファイルだけなので不要なファイルは削除する。

index.htmlにアドオンのファイルを追記。

<!doctype html>
  <html lang="ja">
    <meta charset="UTF-8">
    <title>xterm.js test page</title>
    <head>
      <link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
      <script src="node_modules/@xterm/xterm/lib/xterm.js"></script>
      <script src="node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
      <script src="node_modules/@xterm/addon-attach/lib/addon-attach.js"></script>
      <script src="node_modules/@xterm/addon-clipboard/lib/addon-clipboard.js"></script>
      <script src="node_modules/@xterm/addon-fit/lib/addon-fit.js"></script>
      <script src="node_modules/@xterm/addon-image/lib/addon-image.js"></script>
      <script src="node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
      <script src="node_modules/@xterm/addon-webgl/lib/addon-webgl.js"></script>
      <script src="test.js"></script>
    </head>
    <body>
      <div id="terminal"></div>
    </body>
  </html>

test.jsにもアドオンの初期化等を追記

// 初期化処理
const sshInfo = {
wsport    : {WebSocketのポート番号},
};
window.addEventListener('load', () => {
  const term = new Terminal();
  //
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ');  
  // addon  
  // @xterm/addon-attach
  // initialization
  const webSocket = new WebSocket(`ws://${location.hostname}:${sshInfo.wsport}`);
  const attachAddon = new AttachAddon.AttachAddon(webSocket);
  term.loadAddon(attachAddon);
  // @xterm/addon-clipboard
  // initialization
  const clipboardAddon = new ClipboardAddon.ClipboardAddon();
  term.loadAddon(clipboardAddon);
  // @xterm/addon-fit
  // initialization
  const fitAddon = new FitAddon.FitAddon();
  term.loadAddon(fitAddon);
  fitAddon.fit();
  // @xterm/addon-image
  // customize as needed (showing addon defaults)
  const customSettings = {
    enableSizeReports: true,    // whether to enable CSI t reports (see below)
    pixelLimit: 16777216,       // max. pixel size of a single image
    sixelSupport: true,         // enable sixel support
    sixelScrolling: true,       // whether to scroll on image output
    sixelPaletteLimit: 256,     // initial sixel palette size
    sixelSizeLimit: 25000000,   // size limit of a single sixel sequence
    storageLimit: 128,          // FIFO storage limit in MB
    showPlaceholder: true,      // whether to show a placeholder for evicted images
    iipSupport: true,           // enable iTerm IIP support
    iipSizeLimit: 20000000      // size limit of a single IIP sequence
  }
  // initialization
  const imageAddon = new ImageAddon.ImageAddon(customSettings);
  term.loadAddon(imageAddon);
  // @xterm/addon-web-links
  term.loadAddon(new WebLinksAddon.WebLinksAddon());
  // @xterm/addon-webgl
  term.loadAddon(new WebglAddon.WebglAddon());
});

new ClipboardAddon.ClipboardAddon()とか変な書き方になってるのは

サンプルソースでは new ClipboardAddon() なので実行するとコンストラクタが無いエラーになる。

ブラウザのJavaScriptではグローバルな宣言は、windowオブジェクトにぶら下がるので、

Chromeの開発ツールの監視でwindow値を見ると ClipboardAddon が見つかる

見た感じではClipboardAddon オブジェクトにClipboardAddon クラスがぶら下がっている感じだったのであの様に new している。

多分、TypeScriptのコードをビルドしたソース(というかESModuleコード全般)をJavaScriptから利用するとexportsした名前の下にexportsしたい対象がブラ下がる様に見えるらしい。

/var/www/html/testプロジェクトの雰囲気

  • test
    • index.html
    • test.js
      • node_modules
        • (いっぱい)

後はブラウザから http://localhost/test/ を開く。

topコマンドやviコマンドも普通に使える。

ブラウザで複数のセッションを開けるけど、多人数で使うのは無理。

とても少ないコードでここまで動くのは大助かり。

もっとも

VisualStudioCodeのSSH接続でソース修正やデバッグができるので

もう不要と云えば不要だけどね。

ps.2025/4/9

アドオンがうまく動作していなかったけど、参考を見て紛れてるミスを訂正したらマウスホバーでリンク表示とかCtrl+クリックでジャンプ出来る様になった。



[xterm.js]文字を入力できるだけのサンプル

概ねxterm.jsのサンプルの通りにやってみた。

# mkdir test
# cd test
# npm init ※プロジェクトを初期化

# npm install @xterm/xterm
<!doctype html>
  <html lang="ja">
    <meta charset="UTF-8">
    <title>xterm.js test page</title>
    <head>
      <link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
      <script src="node_modules/@xterm/xterm/lib/xterm.js"></script>
      <script src="test.js"></script>
    </head>
    <body>
      <div id="terminal"></div>
    </body>
  </html>
// 初期化処理
window.addEventListener('load', () => {
  var term = new Terminal();
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ');
  // 何かが送られた時
  term.onData((data) => {
    // とりあえず画面に書く
    if(data === '\r') {
      // 改行して欲しいから差替え
      term.write('\r\n');
    } else {
      term.write(data);
    }
  });
  // 何かキーが押された時
  term.onKey((e) => {
    // 特に何もしない
  });
});

上の2ファイルとnode_modulesディレクトリィをapacheのドキュメントルートのtestフォルダにコピー

  • {apacheのDocumentRoot}
    • test
      • index.html
      • test.js
      • node_modules
        • @xterm
          • xterm
            • css
              • xterm.css
            • lib
              • xterm.js

で、ブラウザからhttp://localhost/testで

【Enter】で行頭へジャンプするけど改行しないのでスクリプトで改行コードを追加した。

【Del】と【BackSpace】キーは無反応。

右クリックでコピペは出来るが【切り取り】は無反応。

複数行で貼り付けると、改行混ざってるからちゃんと置換しないとダメか。

// 初期化処理
window.addEventListener('load', () => {
  var term = new Terminal();
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ');
  // 何かが送られた時
  term.onData((data) => {
    // とりあえず画面に書く
    data = data.replaceAll('\r', '\r\n');  // 改行コードの置換
    term.write(data);
  });
  // 何かキーが押された時
  term.onKey((e) => {
    // 特に何もしない
  });
});

中途半端だけどnode.js入れたついでにlinuxシェルと繋げてみるか?

そのためのアドインがTypeScriptで出来てるっぽいのでどうしたものか?




top