1. 서론
저번 글에서 프로토 타입으로 대략 어떻게 웹터미널을 개발할지 구상이 끝나서 실제 운영 서버에 적용하게 되었다.
이번 글의 코드는 저번 프로토 타입에서 신경쓰지 않은 부분까지 보완해서 작성하였다.
실제 운영서버라 개발 환경이 다르다는 점만 유의하면 될 것 같다.
+ linux의 복붙기능
+ 브라우저 드래그시 화면 맞춤 (흰색 바탕이 노출되는 현상 해결)
+ 브라우저 크기에 맞게 ssh의 크기 조정 (글을 길게 쓰면 위로 덮어 씌워지는 현상 해결)
+ 문자 깨짐 현상으로 인코딩 변경
[개발 환경]
Front : NextJS, TypeScript
Backend : NestJS, TypeScript
+ SSH 로 연결할 서버 하나
[디렉토리 구조]
src
|- app
| |- web-ssh
| |- page.tsx
|- containers
|- web-ssh
|- MySsh.tsx
|- SshSocketConfig.tsx
|- WebSsh.tsx
|- WebSshDraw.tsx
2. Front-end
서버와 연결을 담당할 SshSocketConfig를 먼저 작성한다.
import { Token } from "@/interface/Token.interface";
import { getUserInfo } from "@/utiles/common";
import { NEXT_PUBLIC_API_URL } from "@/utiles/constants";
import { io, Socket } from "socket.io-client";
import { Terminal } from "@xterm/xterm";
class SshSocketConfig{
socket : Socket;
constructor(params : any){
const userInfo: Token | null = getUserInfo();
//NEXT_PUBLIC_API_URL : 환경변수
this.socket = io(NEXT_PUBLIC_API_URL, {transports : ["websocket"], path : "/ssh"});
//파라미터에는 각자 필요한 데이터들을 넣으면 된다. 필수값이 아니다.
this.socket.emit("ssh-connect", {
user : userInfo,
resourceName : params.resourceName,
});
}
//SSH로 연결한 서버에서 출력되는 값을 웹 터미널 화면에 보여주는 역할
init(term : Terminal){
//ssh 연결 완료시 ssh의 크기 조정
this.socket.on("connected", (data: any) => {
this.socket.emit("resize", {
cols: term.cols,
rows: term.rows,
width: window.innerWidth,
height: window.innerHeight
});
});
this.socket.on("output", (data : string) => {
term.write(data);
});
}
//웹 터미널에서 입력한 명령어를 서버로 보내주는 역할
execute(command : string){
this.socket.emit("input", command);
}
//ssh 화면 크기 관련 파라미터를 서버로 보내주는 역할
resize(command : any){
this.socket.emit("resize", command);
}
}
export default SshSocketConfig;
그리고 SshSocketConfig를 사용해야하는 경우가 잦아서 DTO를 만들어준다.
import { Terminal } from "xterm";
export type SocketConfigDTO = {
init: (term: Terminal) => void;
execute: (command: string) => void;
resize: (data: any) => void;
socket: any;
};
그 다음으로는 웹 터미널 화면을 담당하는 MySsh을 작성한다.
이 파일은 화면을 구성하는 코드라 수정할 경우가 거의 없을 것이다.
혹시 insert, delete, home, end, page up, page down 이 작동하지 않는다면, 서버 세팅 문제이다.
굳이 코드에서 수정해줄 필요가 없다.
export TERM=xterm-256color
echo 'export TERM=xterm-256color' >> ~/.bashrc
import { SocketConfigDTO } from "@/interface/SocketConfigDTO";
import { ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
export default class MySsh {
term: Terminal;
_options: ITerminalOptions & ITerminalInitOnlyOptions = {
cursorBlink: true,
scrollSensitivity: 2,
allowProposedApi: true,
};
_fitAddon: FitAddon;
_input: number;
_socket: SocketConfigDTO;
constructor(socket: any) {
this.term = new Terminal(this._options);
this._fitAddon = new FitAddon();
this._input = 0;
this._socket = socket;
this._socket.init(this.term);
this._initWebTerminal();
//linux의 복사 기능 수행
window.addEventListener("mouseup", (e) => {
if(e.button == 0) this.dragCopy();
});
}
open(element: HTMLElement) {
this.term.open(element);
}
fit() {
this._fitAddon.fit();
//xterm의 사이즈 조정 후 서버가 통신하는 ssh서버의 사이즈도 수정하게 전달해준다.
this._socket.resize({
cols: this.term.cols,
rows: this.term.rows,
width: window.innerWidth,
height: window.innerHeight
});
}
//linux의 붙여넣기 기능
paste() {
navigator.clipboard.readText().then(text => {
this._socket.execute(text);
});
}
//linux의 복사 기능
dragCopy(){
const text = this.term.getSelection();
navigator.clipboard.writeText(text);
}
_initWebTerminal() {
this.term.onKey((e) => this._onKeyHandler(e));
this.term.onData((e) => this._onDataHandler(e));
this.term.loadAddon(this._fitAddon);
}
_onKeyHandler(e: { key: string; domEvent: KeyboardEvent }) {
const printable: boolean = !e.domEvent.altKey && !e.domEvent.ctrlKey && !e.domEvent.metaKey;
if (e.domEvent.key === 'Enter') {
this._enter();
} else if (e.domEvent.key === 'Backspace') {
this._backSpace();
} else if (e.domEvent.key === 'ArrowRight') {
this._moveRight();
} else if (e.domEvent.key === 'ArrowLeft') {
this._moveLeft();
} else if (printable) {
this._input++;
}
}
_onDataHandler(e: string) {
this._socket.execute(e);
}
_enter() {
this._input = 0;
}
_backSpace() {
if (this.term.buffer.active.cursorX >= this.term.cols) {
this.term.write('\x1B[0J');
this._input--;
}
}
_moveRight() {
const isEnd: boolean = this.term.buffer.active.cursorX - this.term.cols <= this._input;
if (!isEnd) {
this.term.write('\x1B[C');
}
}
_moveLeft() {
const isStart: boolean = this.term.buffer.active.cursorX >= this.term.cols;
if (!isStart) {
this.term.write('\x1B[D');
}
}
}
그리고 껍데기 WebSsh을 작성한다.
껍데기를 만든 이유는 npm run build 시에 에러가 발생하기에, 화면을 로드할 껍데기를 하나 추가하였다.
"use client"
import dynamic from "next/dynamic";
const WebSshDraw = dynamic(
async() => (await import("@/containers/web-ssh/WebSshDraw")).WebSshDraw,
{ssr : false}
);
export const WebSsh = (params : any) => {
return <WebSshDraw params={params.params}/>
};
해당 이슈는 React에서는 문제가 없었지만 Next에서는 그려지지않은 페이지에 컴포넌트를 랜더링하려 하면 에러가 발생한다.
이 문제에 대해서는 아래 포스팅을 참고하면 된다.
Self is not defined (NextJS & Xterm)
1. Error : Self is not definedReact에서 테스트할 때는 문제가 없었지만, NextJS에서는 에러가 발생하였다.NextJS가 아직 그려지지 않은 페이지에 컴포넌트를 랜더링하려 하기에 발생한다.하여 dynamic
pinokio5600.tistory.com
그럼 이제 실제로 terminal을 그려줄 WebSshDraw.tsx를 작성한다.
"use client"
import { useEffect } from "react";
import "@xterm/xterm/css/xterm.css";
import MySsh from "./MySsh";
import SshSocketConfig from "./SshSocketConfig";
export const WebSshDraw = (params : any) => {
let terminal : MySsh;
useEffect(() => {
const socket = new SshSocketConfig(params.params);
terminal = new MySsh(socket);
terminal.open(document.getElementById("terminal") as HTMLElement);
terminal.fit();
//화면 사이즈 변경시 terminal화면이 꽉 차게 보이기위해 추가
window.addEventListener("resize", () => {
terminal.fit();
});
}, []);
//linux의 우클릭 붙여넣기 기능을 위해 추가
const clickRight = (e : any) => {
e.preventDefault();
terminal.paste();
}
return <div id="terminal" style={{height : "100vh", textAlign : "left"}} onContextMenu={clickRight} />;
};
마지막으로 page.tsx에서 사용하면 된다.
import { WebTerminal } from "@/containers/web-terminal/WebTerminal";
export default function TerminalSection(args : any) {
return (
<WebTerminal params={args.searchParams} />
);
}
3. Back-end
Nest에서는 Socket 통신을 위해 WebSocketGateway를 사용한다.
바뀐점은 데이터를 뿌려주는 부분에서 socket.id를 이용하여 통신을 한 사용자에게만 데이터를 뿌려주는 점이랑
공통으로 사용되던 변수를 개개인에게 통신하기 위해 제거했다.
그러다보니 SubscribeMessage으로 받던 input을 socket.on 메소드로 처리하게 되었다.
import { ConnectedSocket, MessageBody, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { ServerService } from "src/server/server.service";
import { Client } from "ssh2";
@WebSocketGateway()
export class TerminalGateway implements OnGatewayConnection, OnGatewayInit, OnGatewayDisconnect{
@WebSocketServer() server : Server;
constructor(
private serverService : ServerService
){}
afterInit(server : Server){
console.log("afterInit", server , this.server);
}
async handleConnection(client: any, ...args: any[]) {
}
handleDisconnect(client: any) {
console.log("disconnection");
}
@SubscribeMessage("ssh")
async handleSsh(@ConnectedSocket() socket : Socket, @MessageBody() data){
const detail = (await this.serverService.getServerInstance(data.user, data.resourceName)).data;
const instanceNo = detail["INSTANCE_ID"];
const rootPassword = await this.serverService.getRootPassword(instanceNo);
if(rootPassword && rootPassword["statusCode"] == 200 && detail && detail) {
const ssh = new Client();
//ssh 연결
ssh.connect({
host : detail["INTERNAL_IP"],
port: 22,
username: "root",
password: rootPassword["data"].toString()
})
.on("ready", () => {
ssh.shell((err, stream) => {
if(err) console.log("err : ", err);
stream
//클라이언트에게 ssh 데이터를 넘겨준다.
.on("data", (d : any) => {
//binary에서 utf-8로 변경하여 문자 깨짐 방지
this.server.to(socket.id).emit('output', d.toString("UTF-8"));
})
.on("close", () => {
ssh.end();
})
;
//클라이언트에서 온 명령어를 ssh에게 넘겨준다.
//@SubscribeMessage와 같은 기능을 한다.
socket.on("input", (data) => {
stream.write(data)
});
//ssh의 크기를 변경한다.
socket.on("resize", (data) => {
stream.setWindow(data.rows, data.cols, data.height, data.width);
});
});
})
.on("error", (e) => {
console.log("error : ", e.message);
});
}
}
}
'NodeJS' 카테고리의 다른 글
WebRDP (0) | 2024.12.18 |
---|---|
WebTerminal - Prototype (0) | 2024.10.28 |
WebRTC - SFU, Mediasoup (6) | 2024.08.27 |
4. Screen Sharing - Advancement (0) | 2024.08.14 |
3.Screen Sharing - Mouse Event (0) | 2024.08.12 |