• logo

      SeolMyeongTang

  • 웹 터미널로 자기소개 페이지 만들기

    2023년 3월 14일
    설명탕 자기소개 페이지(23.03.11 기준)
    설명탕 자기소개 페이지(23.03.11 기준)

    2023년 1월, Notion API를 활용한 ‘설명탕’ 개인 블로그를 만들었습니다. 어느 정도의 블로그 틀을 만들고 다음 개선사항을 진행하던 중 자기소개 페이지를 만들면 좋겠다고 생각했습니다. HTML 정적 파일로 나를 소개해도 좋지만, 이왕 마음대로 표현할 수 있는 개인 블로그이니 ‘웹 터미널로 자기소개 페이지를 만들자!’라 생각하고 이를 진행하였습니다. 이번 글은 어떻게 웹 터미널로 자기소개 페이지를 만들었는지와 어떻게 보안적으로 설계 했는지에 대한 내용입니다.

    저는 터미널 환경을 좋아합니다. 서버 환경을 하다보니 자연스럽게 터미널 환경에 익숙해지면서 터미널 환경이 갖는 매력에 대해 알게되었습니다. 전에 웹 터미널 관련 글을 썼지만, 아직 실전 production에 활용해본 적이 없어서 아쉬웠습니다. 마침 이번에 자기소개 페이지를 만들 때 웹 터미널을 활용하면 좋을 거 같았습니다. 터미널을 실행하면 ASCII art처럼 문자가 딱 나오고(redis 실행했을 때 redis 로고가 나오는 거 처럼) 간략한 자기소개가 있는 페이지를 만들겠다고 다짐했습니다.

    redis-server 실행 시 나오는 출력물
    redis-server 실행 시 나오는 출력물

    자기소개 페이지를 만들기 위해 첫 번째로 한 일은 웹 터미널을 띄우는 일이었습니다. 설명탕 블로그는 프론트엔드는 NextJS, 백엔드는 NestJS로 구축되어있어서 웹 터미널을 띄우기 위해 다음의 모듈을 사용했습니다. 각각의 모듈들에 대한 설명은 전에 작성한 웹 터미널 글을 참고하면 좋을거 같습니다 :)

    소켓을 이용하여 사용자와 웹 터미널 간 통신하도록 구성했습니다. 클라이언트가 자기소개 페이지에 접속하면 서버 쪽에 pty를 생성하여 사용자가 쓸 터미널을 생성하고 소켓 통신으로 pty의 출력물을 클라이언트로 전달하는 식입니다. 여기까지는 일반적인 웹 터미널 방식과 동일합니다.

    웹 터미널 통신 과정
    웹 터미널 통신 과정

    이제 터미널을 처음 접속 했을 때 출력하게 할 ASCII 문자를 준비해봅시다. 저는 블로그 이름인 SeolMyeongTang과 밑에 자기소개 내용을 적고 싶어서 about 파일에 다음의 내용을 적었습니다. SeolMyeongTang의 문자는 구글에 ASCII art generator 키워드로 검색하여 ASCII 문자를 만들어주는 사이트를 이용하여 만들었습니다.

    TEXT
     ______  ______  ______  __
    /\  ___\/\  ___\/\  __ \/\ \
    \ \___  \ \  __\\ \ \/\ \ \ \____
     \/\_____\ \_____\ \_____\ \_____\
      \/_____/\/_____/\/_____/\/_____/
    
     __    __  __  __  ______  ______  __   __  ______
    /\ "-./  \/\ \_\ \/\  ___\/\  __ \/\ "-.\ \/\  ___\
    \ \ \-./\ \ \____ \ \  __\\ \ \/\ \ \ \-.  \ \ \__ \
     \ \_\ \ \_\/\_____\ \_____\ \_____\ \_\\"\_\ \_____\
      \/_/  \/_/\/_____/\/_____/\/_____/\/_/ \/_/\/_____/
    
     ______  ______  __   __  ______
    /\__  _\/\  __ \/\ "-.\ \/\  ___\
    \/_/\ \/\ \  __ \ \ \-.  \ \ \__ \
       \ \_\ \ \_\ \_\ \_\\"\_\ \_____\
        \/_/  \/_/\/_/\/_/ \/_/\/_____/
    
    Name: Seol Jun
    Email: physiogel@pusan.ac.kr
    GitHub: https://github.com/redundant4u
    Interesting: DevOps, Serverless, Compiler
    Update: 2023/02/22
    

    그리고 웹 터미널 접속 시 기본으로 자기소개 내용이 출력되도록 .bashrc 내용을 수정하였습니다.

    BASH
    cat about
    

    전에 작성한 웹 터미널 코드를 참고하여 프론트엔드, 백엔드 코드를 작성하면 짜잔~ 여기까지가 웹 터미널을 이용한 자기소개 만들기였습니다… 라고 하기에는 고려해야 할 점이 많습니다. 자기소개를 한다는 측면에서 보면 기능적으로 문제없으나 보안 측면에서 매우 큰 위험이 있습니다. 제가 생각한 보안 측면인 위험은 다음과 같았습니다.

    1. 서버 pty라서 서버 코드 유출 및 데이터 유실 우려가 있다
    2. 서버 pty라서 서버가 안전하지 않다(ec2가 안전하지 않다)
    3. 사용자가 exit 같은 명령어로 터미널 세션을 종료하지 않으면 쉘 프로세스가 계속 살아있다(서버 자원 낭비가 발생한다)

    위 3개 전부 모두 정말 끔찍한 보안 사고입니다. 저는 그저 웹 터미널을 통한 자기소개 페이지를 만들고 싶었을 뿐인데, 서버가 털릴 창구를 만든 셈입니다. 위 문제들을 해결하기 위해 몇 가지의 안전장치를 걸어두었습니다.

    1. 웹 터미널 접속을 위한 docker 컨테이너
    2. 별도의 리눅스 계정 생성
    3. 자동 로그아웃 및 자동 완성 해제
    4. docker 컨테이너 자원 제한
    5. rbash 적용
    6. 명령어 제한

    가장 중요하다고 생각한 것은 서버 내용이 유출되지 않게 하는 것입니다. 이를 위해 사용자가 쓰는 pty는 서버로부터 독립적으로 구성해야 한다고 생각했습니다. 독립적으로 구성한다고 해서 다른 서버의 pty를 쓰기에는 복잡하고 불필요한 관리 포인트가 늘어나서 터미널 접속을 위한 docker 컨테이너를 만들었습니다. 클라이언트가 웹 터미널 접속 소켓 통신이 오면 ssh 접속으로 터미널 컨테이너와 연결하도록 구성했습니다. 컨테이너 간 ssh 접속은 공개키 방식으로 연결되고 키 파일은 볼륨으로 연결되어있습니다.

    서버 컨테이너의 terminal 컨테이너 접속
    서버 컨테이너의 terminal 컨테이너 접속

    따라서 사용자가 이용하는 웹 터미널 환경은 서버 컨테이너와는 다른 컨테이너 위에서 동작하므로 보안 사고가 일어난다고 한들 서버 코드 유출로부터 안전합니다.

    서버 코드 유출 및 유실로부터의 위험성이 사라졌지만, 만약 사용자가 임의로 pty 생성을 계속하게 된다면 어떻게 될까요? 사용자가 exit, logout 명령어로 터미널 세션을 종료하지 않는 이상 터미널 컨테이너 백그라운드에는 쉘 프로세스가 계속 떠있습니다. 이는 서버 자원의 낭비로 이어지고 요금 및 서버 성능 문제로 직결될 수 있습니다. 이를 방지하기 위해 터미널 자동 로그아웃docker 컨테이너 자원 제한 옵션을 설정하였습니다.

    자기소개 페이지를 머무르는 시간은 짧을거라 판단하여 터미널 유지 시간을 5분으로 설정하여 5분이 지나면 자동으로 세션을 종료하도록 설정하였습니다. 또한 터미널 컨테이너가 쓸 수 있는 자원을 제한하여 서버 자원이 낭비되지 않도록 설정하였습니다. 이외에도 소켓 연결이 끊어지면 pty 프로세스가 종료되도록 설정하였습니다.

    BASH
    export TMOUT=300
    
    YAML
    terminal:
        container_name: terminal
        image: terminal:prod
        privileged: true
        deploy:
          resources:
            limits:
              cpus: "0.5"
              memory: 32M
         expose:
           - 22
    

    docker 컨테이너는 기본적으로 root 계정으로 로그인합니다. 컨테이너 안이기는 하지만 사용자가 컨테이너안에서 행동하는 반경을 제한하고자 권한을 낮은 새로운 계정을 만들어 로그인 하도록 합니다. 또한 사용자가 명령어를 이용하여 악의적인 행위를 할 수 있으니 명령어도 제한하는것이 안전합니다. 명령어를 제한하기 위해 rbash.bashrc를 이용하였습니다.

    rbash는 restricted bash로 파일 쓰기(touch, cat), 디렉터리 이동(cd) 명령어가 제한되는 쉘입니다. 더욱 안전한 환경을 만들기 위해 특정 명령어만 쓸 수 있도록 설정할 수 있습니다. 먼저 허용할 명령어를 복사하고 .bashrcPATH에 관한 내용을 적으면 특정 명령어만 쓸 수 있도록 합니다.

    BASH
    WORKDIR="/home/seol"
    
    # Create New User
    useradd -U -u 999 -d ${WORKDIR} -m -l seol
    
    # Disable Linux command
    mkdir -p ${WORKDIR}/bin
    
    cp /bin/ls ${WORKDIR}/bin/ls
    cp /bin/cat ${WORKDIR}/bin/cat
    cp /bin/clear ${WORKDIR}/bin/clear
    
    echo export PATH="${WORKDIR}/bin" >> $WORKDIR/.bashrc
    

    .bashrc 파일 내용은 다음과 같습니다.

    TEXT
    cat about
    
    export TMOUT=60
    export TERM=xterm
    export PATH="/home/seol/bin"
    export PS1=">> "
    

    몇 가지의 안전장치를 설정하여 안전하고 만족스러운 자기소개 웹 터미널 환경을 만들었습니다. 두근대는 마음으로 배포를 하였는데 예상하지 못한 버그를 만났습니다. 여러 탭을 띄워 웹 터미널을 실행했을 때 마지막에 띄운 웹 터미널만 입력받는다는 것입니다.

    마지막에 띄운 웹 터미널에만 입력이 되는 버그
    마지막에 띄운 웹 터미널에만 입력이 되는 버그

    여러 추측과 조사 끝에 해당 버그 원인을 찾았습니다. 클라이언트의 요청이 올 때마다 터미널 컨테이너에 pty 프로세스가 할당이 되지만, 소켓 통신하는 도중 마지막 pty에만 input write가 적용되는 문제였습니다. 문제가 되는 코드는 서버 측 코드로 24번 줄과 26~28번 줄을 참고하면 항상 마지막에 생성된 pty로 input을 받고 output을 내도록 하여 버그가 발생한다는 것을 추측할 수 있습니다.

    TSX
      1 @WebSocketGateway({
      2     namespace: 'terminal',
      3     cors: { origin: ['https://redundant4u.com'] },
      4     transports: ['websocket'],
      5 })
      6 export default class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
      7     private pty: IPty;
      8
      9     handleConnection(@ConnectedSocket() socket: Socket) {
     10         Logger.log(`connect: ${socket.id}`);
     11     }
     12
     13     handleDisconnect(@ConnectedSocket() socket: Socket) {
     14         Logger.log(`disconnect: ${socket.id}`);
     15     }
     16
     17     @SubscribeMessage('init')
     18     init(@ConnectedSocket() socket: Socket) {
     19         this.pty = spawn('ssh', ['terminal'], {
     20             name: 'terminal',
     21             cwd: process.env.HOME,
     22         });
     23
     24         this.pty.onData((data) => socket.emit('output', data));
     25
     26         socket.on('input', (data) => {
     27             this.pty.write(data);
     28         });
     29     }
     30 }
    

    이를 해결하기 위해 클라이언트가 웹 터미널을 만들 때 socket id와 pty 정보를 Map 형태로 저장합니다. 사용자가 웹 터미널을 이용하면 해당되는 사용자의 pty를 가져와 다른 pty와 충돌되지 않게 입력을 받고 출력을 하는 코드로 수정하여 버그를 해결했습니다.

    TSX
      1 @WebSocketGateway({
      2     namespace: 'terminal',
      3     cors: { origin: ['https://redundant4u.com'] },
      4     transports: ['websocket'],
      5 })
      6 export default class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
      7     private ptys: Map<string, IPty> = new Map();
      8
      9     handleConnection(@ConnectedSocket() socket: Socket) {
     10         Logger.log(`[Socket Connect]: ${socket.id}`);
     11     }
     12
     13     handleDisconnect(@ConnectedSocket() socket: Socket) {
     14         Logger.log(`[Socket Disconnect]: ${socket.id}`);
     15
     16         const { id } = socket;
     17         const pty = this.ptys.get(id);
     18
     19         if (pty) {
     20             pty.kill();
     21             this.ptys.delete(id);
     22         }
     23     }
     24
     25     @SubscribeMessage('init')
     26     init(@ConnectedSocket() socket: Socket) {
     27         const pty = spawn('ssh', ['terminal'], {
     28             name: 'xterm-color',
     29             cwd: process.env.HOME,
     30         });
     31
     32         pty.onData((data) => {
     33             socket.emit('output', data);
     34         });
     35
     36         const { id } = socket;
     37         this.ptys.set(id, pty);
     38
     39         socket.on('input', (data) => {
     40             const pty = this.ptys.get(id);
     41
     42             if (pty) {
     43                 pty.write(data);
     44             }
     45         });
     46     }
     47 }
    

    소켓 통신 과정을 간략하게 본다면 아래와 같습니다.

    웹 터미널 소켓 통신 과정
    웹 터미널 소켓 통신 과정

    위의 과정들을 거치며 제가 생각하는 자기소개 웹 터미널을 만들 수 있었습니다. 기획한 대로 구현이 된 거 같아 볼 때마다 뿌듯한 결과물이 나온 거 같습니다. 특별한 자기소개 페이지를 만들고 싶다면 웹 터미널을 이용한 자기소개는 어떤가요? 전체적인 웹 터미널 프론트엔드 코드는 여기에서 백엔드 코드는 여기에서 참고하실 수 있습니다.