Web

[Web] Java Spring을 이용한 Web Terminal 구현하기

메바동 2021. 2. 18. 22:07
728x90

Java Spring과 web socket, JSch를 이용하여 웹에서 SSH를 접속하는 Terminal을 구현해 보았다.

프론트는 React를 잘 모르지만 React스럽지 않은 React를 사용하고, Xterm.js를 이용해 Terminal 화면을 띄어주고 sockjs-client를 이용해 소켓 통신을 사용하였다.

 

제대로 만들지 못했지만 그래도 정상적으로 작동하기 때문에 뿌듯해서 블로그에 글을 올려본다.

 

어찌됐든 돌아는 가니까...
돌아는 간다...

 

사용한 라이브러리들의 라이선스는

spring-websocket은 Apache 2.0

JSch는 BSD

Xterm.js와 sockjs-client는 MIT

라이선스를 사용하고 있다.

 

우선 Spring에서는 pom.xml에 maven repository에서 spring-websocket과 JSch를 가져와 추가해준다.

 

pom.xml

...
<!-- https://mvnrepository.com/artifact/org.springframework/spring-websocket -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>${org.springframework-version}</version>
</dependency>
...
<!-- https://mvnrepository.com/artifact/com.jcraft/jsch -->
<dependency>
    <groupId>com.jcraft</groupId>
    <artifactId>jsch</artifactId>
    <version>0.1.55</version>
</dependency>

 

spring-websocket은 사용하고 있는 스프링 버전에 맞춰서 사용하면 되는데, 나는 5.0.7.RELEASE 버전을 사용하였다.

 

다음은 handler bead을 등록하고, websocket handler로 등록해주면 된다.

bean id와 class는 프로젝트에 맞게 수정하고, handler의 handler와 path도 원하는 대로 지정해서 사용하면 된다.

<bean id="sshHandler" class="com.web.ssh.socket.SshHandler" />

<websocket:handlers allowed-origins="*">
	<websocket:mapping handler="sshHandler" path="/ssh" />
	<websocket:sockjs />
</websocket:handlers>

 

이제 handler를 구현하면 된다.

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.web.ssh.domain.SshDto;
import com.web.ssh.service.SshConnectionService;
import com.web.ssh.service.SshService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SshHandler implements WebSocketHandler {
	
	@Autowired
	private SshService service;
	
	@Autowired
	private SshConnectionService conService;

	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		// 웹 소켓 연결		
		log.info("{} 연결됨", session.getId());
	}

	@Override
	public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
		if (message.getPayload().toString().contains("OPEN WEB SOCKET")) {
			ObjectMapper om = new ObjectMapper();
			SshDto dto = om.readValue(message.getPayload().toString(), SshDto.class);
			dto = service.getHostInfo(dto);
			conService.initConnection(session, dto);
		}
		
		conService.recvHandle(session, message.getPayload().toString());
	}

	@Override
	public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
		// TODO Auto-generated method stub
		
	}

	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
		conService.close(session);
	}

	@Override
	public boolean supportsPartialMessages() {
		// TODO Auto-generated method stub
		return false;
	}
	
}

 

WebSocketHandler 인터페이스를 구현하는 SSHHandler를 만들어주고 다음과 같이 사용하였다.

나는 SSH에 접속할 host를 DB에 저장하고 불러오는 식으로 사용했기 때문에 저런 식으로 만들어 주었다.

 

크게 사용하는 건 웹 소켓이 정상적으로 연결되었는지 확인하는 afterConnectionEstablished와 메시지를 받았을 때 처리하는 handleMessage, 그리고 연결이 끊길 때 처리해주는 afterConnectionClosed 이 세 부분이다.

 

사실 afterConnectionEstablished도 연결이 됐다고 알려주는 log를 띄어줄 뿐이지 모든 작업은 handleMessage가 담당한다.

sockjs-client에서 소켓에 연결할 때 연결 정보와 'OPEN WEB SOCKET'이라는 메시지를 담은 json 객체를 전송하고, 해당 메시지의 host 정보를 DB에서 찾아 연결해 주는 부분이다.

 

지금 블로그에 글을 쓰면서 느끼는 건데 설계 없이 마구잡이로 제작에 들어가니 코드가 이상한 것 같다.

그래도 돌아는 가니까...

 

나는 언제쯤 제대로 프로그래밍을 할 수 있을까... 뭔가 만들 때마다 부족한게 많다는걸 열심히 느끼게 된다.

 

위에서 사용된 SshDto는

 

import lombok.Data;

@Data
public class SshDto {
	
	private String	type;
	private String 	host;
	private int	port;
	private String	username;
	private String	password;
	
}

 

이렇게 되어있다. 그냥 SSH 연결에 필요한 최소한의 정보인 host, port, username, password를 담게 된다. type은 연결할 때 'OPEN WEB SOCKET'를 저장할 때만 사용하고 사용하지 않는데... 그냥 생각 없이 만들다 보니 이걸 어떻게 해야 할지 모르겠어 그냥 이렇게 진행했다.

 

또 다른 DTO로 SshConnetctionDto를 사용하는데

 

import org.springframework.web.socket.WebSocketSession;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.JSch;

import lombok.Data;

@Data
public class SshConnectionDto {
	
	private WebSocketSession	session;
	private JSch			jsch;
	private Channel			channel;
	private SshDto			info;

}

 

여기는 소켓 세션 정보와 SSH 연결 정보인 JSsh, Channel을 담아준다.

위에 나오는 SshService는 DB에서 host 정보를 저장하고 삭제하고 읽어오는 등의 작업을 할 때 사용한다. 딱히 여기서 설명할 필요는 없는 파일이라 올리지는 않으려고 한다.

 

다 설명하기는 너무 귀찮고 누워서 쉬고 싶기 때문이다. :)

 

웹 소켓의 처리는 SshConnectionService에서 처리한다.

 

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.web.ssh.domain.SshConnectionDto;
import com.web.ssh.domain.SshDto;

import lombok.extern.log4j.Log4j;

@Service
@Log4j
public class SshConnectionService {
	
	private static Map<WebSocketSession, Object> sshMap = new ConcurrentHashMap<>();
	
	private ExecutorService executorService = Executors.newCachedThreadPool();

	public void initConnection(WebSocketSession session, SshDto dto) {
		JSch jsch = new JSch();
		SshConnectionDto connectionDto = new SshConnectionDto();
		connectionDto.setJsch(jsch);
		connectionDto.setSession(session);
		connectionDto.setInfo(dto);
		sshMap.put(session, connectionDto);
		
		executorService.execute(new Runnable() {
			@Override
            public void run() {
				try {
					connectToSSH(connectionDto, dto, session);
				} catch (JSchException | IOException e) {
					log.error("에러 정보: {}", e);
					close(session);
				}
			}
		});
	}
	
	public void recvHandle(WebSocketSession session, String command) {
		SshConnectionDto connectionDto = (SshConnectionDto) sshMap.get(session);
		
		if (connectionDto != null) {
			try {
				transToSSh(connectionDto.getChannel(), command);
			} catch (IOException e) {
				log.error("에러 정보: {}", e);
				close(session);
			}
		}
	}
	
	public void sendMessage(WebSocketSession session, byte[] buffer) throws IOException {
		session.sendMessage(new TextMessage(buffer));
	}
	
	public void close(WebSocketSession session) {
		SshConnectionDto connectionDto = (SshConnectionDto) sshMap.get(session);
		if (connectionDto != null) {
			if (connectionDto.getChannel() != null) connectionDto.getChannel().disconnect();
			sshMap.remove(session);
		}
	}
	
	private void connectToSSH(SshConnectionDto connectionDto, SshDto dto, WebSocketSession webSocketSession) throws JSchException, IOException {
		Session session = null;
		Properties config = new Properties();
		config.put("StrictHostKeyChecking", "no");
		
		session = connectionDto.getJsch().getSession(dto.getUsername(), dto.getHost(), dto.getPort());
		session.setConfig(config);
		
		session.setPassword(dto.getPassword());
		session.connect(60000);
		
		Channel channel = session.openChannel("shell");
		
		channel.connect(3000);
		connectionDto.setChannel(channel);
		
		InputStream is = channel.getInputStream();
		try {
			byte[] buffer = new byte[1024];
			int i = 0;
			while((i = is.read(buffer)) != -1) {
				sendMessage(webSocketSession, Arrays.copyOfRange(buffer, 0, i));
			}
		} finally {
			session.disconnect();
			channel.disconnect();
			if (is != null) {
				is.close();
			}
		}
	}
	
	private void transToSSh(Channel channel, String command) throws IOException {
		if (channel != null) {
			OutputStream os = channel.getOutputStream();
			if (command.equals("SIGINT")) {
				os.write(3);
			} else if(command.equals("SIGTSTP")) {
				os.write(26);
			} else {
				os.write(command.getBytes());
			}
			os.flush();
		}
	}

}

 

소켓이 연결되면 JSch를 생성하고 SSH를 shell 채널로 연결한다. 그리고 소켓에서 메시지를 주고받는 것들을 처리해준다.

 

 

 

JSch의 샘플과 인터넷에서 발견한 글을 많이 참조한 부분이다.

 

프론트 부분은 너무 대충 만들었고 React에 대한 이해도가 심히 떨어지기 때문에... 그냥 대충 만들었다.

 

 

그럼 그냥 HTML로 만들거나 JSP로 만들면 되지, 왜 React를 사용했냐면... 그냥 사용하고 싶었기 때문이다.

 

import React, { useRef, useEffect } from 'react';
import { XTerm } from 'xterm-for-react'
import * as SockJS from 'sockjs-client';

const Termianl = ({socketClient, host}) => {
  const xtermRef = useRef(null);

  useEffect(() => {
    return () => {
      socketClient.close();
    }
  }, [host, username, socketClient]);

  socketClient.onmessage = function(e) {
    xtermRef.current.terminal.write(e.data);
  }

  const onData = (data) => {
    const code = data.charCodeAt(0);

    // ctrl + c
    if (code === 3) {
      socketClient.send('SIGINT');
      return;
    }

    // ctrl + z
    if (code === 26) {
      socketClient.send('SIGTSTP');
      return;
    }

    // backspace
    if (code === 127) {
      socketClient.send('\b');
      return;
    }

    // esc key
    if (code === 27 && data.length === 1) {
      socketClient.send('\x1B');
      return;
    }

    // up key
    if (code === 27 && data.includes('[A')) {
      socketClient.send('\x1b[A');
      return;
    }

    // down key
    if (code === 27 && data.includes('[B')) {
      socketClient.send('\x1b[B');
      return;
    }

    // right key
    if (code === 27 && data.includes('[C')) {
      socketClient.send('\x1b[C');
      return;
    }

    // left key
    if (code === 27 && data.includes('[D')) {
      socketClient.send('\x1b[D');
      return;
    }

    // vi up key
    if (code === 27 && data.includes('OA')) {
      socketClient.send('\x1bOA');
      return;
    }

    // vi down key
    if (code === 27 && data.includes('OB')) {
      socketClient.send('\x1bOB');
      return;
    }

    // vi right key
    if (code === 27 && data.includes('OC')) {
      socketClient.send('\x1bOC');
      return;
    }

    // vi left key
    if (code === 27 && data.includes('OD')) {
      socketClient.send('\x1bOD');
      return;
    }

    // tab
    if (code === 9) {
      socketClient.send('\t');
      return;
    }

    // enter
    if (code === 13) {
      if (terminalState.input === 'exit') {
        socketClient.close();
        window.location.pathname = '/'
        return;
      }
      socketClient.send("\r");

      setTerminalState({ input: '' });
    } else if (code < 32) {
      return;
    } else {
      socketClient.send(data);
    }
  };

  return (
    <div>
      <XTerm ref={xtermRef} onData={onData} />
    </div>
  )
}

const TerminalForm = ({host}) => {
  const sock = new SockJS('http://localhost:8080/ssh');

  const hostInfo = {
    type: "OPEN WEB SOCKET",
    host,
  }

  sock.onopen = () => {
    sock.send(JSON.stringify(hostInfo));
  }

   return (
    <TerminalFormBlock>
      <Termianl socketClient={sock} host={host}/>
    </TerminalFormBlock>
  );
};

export default TerminalForm;

 

핵심 부분만 적자면 이런 형식이다.

 

페이지가 열리면 SockJS를 연결해준다. 이때 위에서 말한 'OPEN WEB SOCKET'이라는 메시지와 함께 host를 전달한다. 사실은 다른 key 값을 써야 하는 게 맞지만 어차피 테스트용으로 만든 것이기 때문에 그냥 host 값을 key 값으로 사용했다.

 

그리고 페이지에서 나갈 때는 소켓 연결을 닫아 주도록 하였다.

 

소켓에서 메세지를 받을 때마다 Xterm.js의 terminal에 값을 기록하도록 하였고, 사용자의 입력을 소켓에 보내 통신을 하도록 하였다.

 

 

아직 React를 제대로 배우지 않았지만, 다시 봐도 못 만든 코드란 게 느껴진다.

더 열심히 공부하고 나서 다시 한 번 봐야겠다.

 

 

그래도 나름 ctrl + z, c와 방향키, tab 기능도 열심히 만들었다.

아마 이게 지금 내 상태에서는 최선이다.

 

 

웬만한 기능은 사용할 수 있는 web terminal을 만들었다.

 

실력은 형편 없어도 만들어진 결과물을 보면 뿌듯하다.

728x90