들어가며
회사에서 개발 중인 서비스가 통신하기 위한 서버가 특정 서버에서만 접근 가능한 상황이었다.
백그라운드에서 동작하는 (유저가 접근하지 않아도 되는) 어플리케이션은 그 특정 서버에 서비스를 띄워서 서로 통신이 가능한 방식을 사용하고 있었지만 개발 중인 서비스가 동작 중인 서버는 별도의 서버로 분리가 되어있었고, 이 서버는 정책상 특정 서버에서 인바운드가 막혀있는 상태였다.
조금 복잡한데 아무튼 개발 중 서비스 -> 타겟 서버로의 직접 접근이 안된다는 말이다.
이 문제를 해결하기 위해서는 타겟 서버로의 접속을 우회하는 방식을 사용해야하는데 이걸 SSH 터널링이라고 한다.
이번에는 SSH 터널링의 개념과 필요성, 구현 예시에 대해 작성해보고자 한다.
SSH (Secure Shell)
시큐어 셸(Secure SHell, SSH)은 네트워크 상의 다른 컴퓨터에 로그인하거나 원격 시스템에서 명령을 실행하고 다른 시스템으로파일을 복사할 수 있도록 해 주는 응용 프로그램 또는 그 프로토콜을 가리킨다.
출처 - 위키백과
쉽게 말하면 원격 서버에 접속하기 위한 도구 라고 생각하면 된다. 사이드 프로젝트의 서버를 담당해본 사람이라면 아마 대부분 알고있지 않을까 싶다.
ssh 명령어는 아래처럼 사용 가능하다. -i
옵션은 key 파일로 서버에 접근해야하는 경우 사용하고, password를 사용한다면 옵션 없이 입력해도 된다.
ssh {user}@{host} -i {idendtity_file}
SSH의 Default Port는 22번이다. 따라서 telnet
을 사용해서 22번 포트가 열려있는 지 확인하려면 아래처럼 작성하면 된다.
telnet {host} 22
접근이 제한되어있는 서버에 접속하려면?
현재 서버인 A 서버와 원격 서버인 B 서버가 있다고 가정하자.
A 서버 -> B 서버로의 통신은 불가능한데, 또 다른 서버인 C 서버 -> B 서버로의 접근이 가능하다. 또, A 서버 -> C 서버로의 접근 또한 가능하다.
이런 경우에 A 서버는 C 서버에 접속한 다음 B 서버로의 접속이 가능하다.
물론 직접 ssh 명령어를 하나하나 입력해서 B 서버까지 도달하는 방법도 있지만 보다 간단하게 A 서버에서 B 서버로의 접근이 가능한 방법이 있다.
이를 SSH 터널링 혹은 SSH 포트 포워딩이라고 한다.
일반적으로 이 C 서버를 '점프 서버(Jump Server)' 혹은 '배스쳔 서버(Bastion Server)'라고 부르고,
B 서버를 '리모트 서버(Remote Server)' 혹은 '타겟 서버(Target Server)'라고 부른다.
SSH 터널링 / SSH 포트 포워딩
둘은 비슷한 개념이지만 사실 개념에서 약간의 차이가 있다.
SSH 터널링 (SSH Tunneling)
SSH 터널링은 보안이 강화된 특별한 '터널'을 만들어서, 두 서버 사이에 안전하게 정보를 주고받을 수 있게 해주는 기술이다.
SSH 포트 포워딩 (SSH Port Forwarding)
SSH 포트 포워딩은 이런 '터널'을 통해서 특정 포트를 열어주는 기술이다. 포트 포워딩을 통해 서버와 클라이언트 사이의 통신을 안전하게 하거나, 보안 정책으로 인해 직접 접근이 불가능한 서버에 접근할 수 있다.
포트 포워딩은 로컬 포트 포워딩(Local Port Forwarding)과 리모트 포트 포워딩(Remote Port Forwarding), 그리고 다이나믹 포트 포워딩(Dynamic Port Forwarding, SOCKS Proxy)으로 나뉜다.
로컬 포트 포워딩
로컬 시스템(127.0.0.1 or localhost)의 특정 포트(LOCAL_PORT)를 리모트 서버의 특정 포트(REMOTE_PORT)와 연결한다.
클라이언트가 localhost:{LOCAL_PORT}
로 요청을 보내면, 그 요청은 SSH 터널을 통해서 {REMOTE_HOST}:{REMOTE_PORT}
로 전달된다.
리모트 포트 포워딩
리모트 서버의 특정 포트(REMOTE_PORT)를 로컬의 특정 포트(LOCAL_PORT)와 연결한다.
리모트 서버에서 localhost:{REMOTE_PORT}
로 요청을 보내면, 그 요청은 SSH 터널을 통해서 localhost:{LOCAL_PORT}
로 전달된다.
다이나믹 포트 포워딩
로컬에서 설정한 동적 포트를 사용하여 SOCKS 프록시 서버를 구성한다. 이 SOCKS 프록시를 통해 여러 리모트 호스트와 포트로의 동적인 연결이 가능해진다.
결국 간단하게 말하면 SSH 터널링은 안전한 '도로'를 만드는 거고, SSH 포트 포워딩은 그 '도로' 위에서 특정 '출입구'를 열어주는 방식이다.
구현 예제
Spring boot에서 SSH를 사용하기 위한 몇가지 라이브러리가 존재하는데 이 예제에서는 JSch 라이브러리를 사용하였다.
Dependency
- Maven
<!-- JSch for ssh -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
- Gradle
// JSch for ssh
implementation 'com.jcraft:jsch:0.1.55'
최종 코드
최종 코드는 다음과 같다.
import com.jcraft.jsch.*;
import com.roomio.linkapiserver.exception.CustomException;
import com.roomio.linkapiserver.exception.errorCode.CommonErrorCode;
import lombok.extern.log4j.Log4j2;
import java.io.IOException;
import java.io.InputStream;
@Log4j2
class SshServiceExample {
public static void main(String[] args) throws IOException {
String jumpServerHost = "점프 서버 IP";
String jumpServerUser = "점프 서버 USER";
String jumpServerIdentityPath = "점프 서버 key file path";
String remoteServerHost = "리모트 서버 IP";
String remoteServerUser = "리모트 서버 USER";
String remoteServerPassword = "리모트 서버 PASSWORD";
String request = "실행할 명령어";
StringBuilder response = new StringBuilder();
Session jumpSession = null;
Session remoteSession = null;
ChannelExec channel = null;
try {
// JSch 로그 설정
JSch.setLogger(new Logger() {
@Override
public boolean isEnabled(int level) {
return true;
}
@Override
public void log(int level, String message) {
log.debug("========== JSch Logger==========\n==========Level: {}, Message: {} ===========", level, message);
}
});
// JSch 객체 생성
JSch jsch = new JSch();
// First SSH session: application server -> jump server
jumpSession = jsch.getSession(jumpServerUser, jumpServerHost, 22);
// Private Key 설정
jsch.addIdentity(jumpServerIdentityPath);
// SSH에 처음 연결할 때, 리모트 호스트의 공개키가 자신의 known_hosts 파일에 저장된 것과 일치하는 지 검사하고 저장할 지 확인
// no로 설정하면 보안에 취약해지므로 신뢰할 수 없는 네트워크에서는 사용을 지양해야함.
jumpSession.setConfig("StrictHostKeyChecking", "no");
// SSH 연결
jumpSession.connect();
log.info("SSH session1 connected");
// Second SSH session: jump server -> remote server
// 포트포워딩 설정을 해준다. Lport를 0으로 설정하면 자동으로 남는 포트에 바인딩 된다.
/* 중요 */
// jump server에는 사용 중이 아닌 포트가 (남는 포트)가 존재해야한다.
int assigned_port = jumpSession.setPortForwardingL(0, remoteServerHost, 22); // 자동으로 바인딩된 포트번호
// remote 서버를 열 때는 localhost로 열어야 한다.
remoteSession = jsch.getSession(remoteServerUser, "localhost", assigned_port);
remoteSession.setPassword(remoteServerPassword);
remoteSession.setConfig("StrictHostKeyChecking", "no");
remoteSession.connect();
log.info("SSH session2 connected");
// exec 타입의 채널 오픈. 이 채널에서 Command 사용 가능
channel = (ChannelExec) remoteSession.openChannel("exec");
// 실행할 Command 세팅
channel.setCommand(request);
// 채널 입력 스트림 설정
// 입력 스트림 : 데이터가 프로그램으로 들어오는 통로. 여기서는 SSH 채널에서 명령어의 실행 결과를 읽어 오는 데 사용됨
// 입력 스트림을 null로 설정하는 이유?
// 단방향 통신: 이 예제에서는 서버에 명령을 내리고 그 결과만 읽어오면 된다. 즉, 명령 실행 후에 추가적으로 서버에 보낼 데이가 없고, 이런 경우 입력 스트림이 필요 없으므로 null로 설정한다.
// 명령어 독립성: 명령어가 실행되는 동안에는 다른 입력을 받을 필요가 없음. 명령어는 이미 setCommand(command);로 설정되었기 때문에 추가적인 입력 스트림은 필요하지 않다..
// 리소스 절약: 불필요한 스트림을 열지 않음으로써 리소스를 절약할 수 있다.
// setInputStream(null);을 호출함으로써, JSch는 서버로부터 데이터를 읽어올 때만 스트림을 사용하고, 서버에 데이터를 보낼 때는 스트림을 사용하지 않게 된다. 이렇게 하면 명령어 실행과 결과 수집에만 집중할 수 있음.
channel.setInputStream(null);
channel.setErrStream(System.err);
// 명령어의 출력을 받을 스트림을 가져옴
InputStream input = channel.getInputStream();
// 채널을 연결하여 명령어 실행
channel.connect();
log.info("channel.isConnected() : {}", channel.isConnected());
// 결과값 가져오기
byte[] buffer = new byte[1024];
while (true) {
int bytesRead = input.read(buffer, 0, buffer.length);
if (bytesRead < 0) {
break;
}
response.append(new String(buffer, 0, bytesRead));
}
} catch (JSchException e) {
throw new CustomException(CommonErrorCode.SSH_CONNECTION_FAILED.getCode(), CommonErrorCode.SSH_CONNECTION_FAILED.getMsg(), e.getMessage());
} finally {
// 채널과 세션 닫기
// session은 자동으로 연결이 끊어지지 않기 때문에 finally 키워드로 직접 연결을 끊어줘야한다.
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (remoteSession != null && remoteSession.isConnected()) {
remoteSession.disconnect();
}
if (jumpSession != null && jumpSession.isConnected()) {
jumpSession.disconnect();
}
}
// 응답값 출력
System.out.println(response);
}
}
각 단계별로 코드를 살펴보도록 하자. 기본 변수 세팅 부분은 넘어가도록 한다.
JSch 로그 설정 및 객체 생성
// JSch 로그 설정
JSch.setLogger(new Logger() {
@Override
public boolean isEnabled(int level) {
return true;
}
@Override
public void log(int level, String message) {
log.debug("========== JSch Logger==========\n==========Level: {}, Message: {} ===========", level, message);
}
});
// JSch 객체 생성
JSch jsch = new JSch();
JSch는 자체적으로 Logger 인터페이스를 제공한다.
이 인터페이스를 상속 받아서 구현을 해도 되지만, 이번에는 익명클래스를 사용해서 간단하게 Logger를 구현했다.
boolean isEnabled(int level)
: 인자로 받은 로그 레벨에 대한 로깅이 활성화 되어있는 지 확인
다음과 같이도 사용 가능하다.
가능한 로그 레벨은DEBUG
,INFO
,WARN
,ERROR
,FATAL
이 있다.
@Override
public boolean isEnabled(int level) {
return level == Logger.DEBUG;
}
void log(int level, String message)
: 로그 메세지 출력. level의 실제 값은 아래를 참고하자.
int DEBUG = 0;
int INFO = 1;
int WARN = 2;
int ERROR = 3;
int FATAL = 4;
로깅을 설정한 다음에는 new JSch();
로 객체를 생성해주면 JSch를 사용할 준비가 끝났다.
다음에는 ssh에 접속해서 터널링까지 진행해보자.
SSH Session 연결 및 포트 포워딩(터널링)
// First SSH session: application server -> jump server
jumpSession = jsch.getSession(jumpServerUser, jumpServerHost, 22);
jsch.addIdentity(jumpServerIdentityPath); // Private Key 설정
jumpSession.setConfig("StrictHostKeyChecking", "no");
jumpSession.connect(); // SSH 연결
log.info("SSH Jump session connected");
// Second SSH session: jump server -> remote server
int assigned_port = jumpSession.setPortForwardingL(0, remoteServerHost, 22); // 자동으로 바인딩된 포트번호
remoteSession = jsch.getSession(remoteServerUser, "localhost", assigned_port);
remoteSession.setPassword(remoteServerPassword);
remoteSession.setConfig("StrictHostKeyChecking", "no");
remoteSession.connect();
log.info("SSH Remote session connected");
SSH를 사용하기 위한 Session 객체를 만들어 connect하는 부분이다.
해당 코드는 현재 서버 구조상 local -> jump -> remote -> target 형태이기 때문에 Session을 두번 생성하고 있지만 일반적인 경우라면 Second SSH session 부분 하나로도 터널링이 가능하다.
이 코드에서 주목해야할 부분은 setPortForwardingL()
부분인데, 차근차근 따라가면서 이해해보자.
- Application Server -> Jump Server
jumpSession = jsch.getSession(jumpServerUser, jumpServerHost, 22);
로컬 서버(Spring application server)에서 점프 서버로 접속하기 위한 Session을 연다. 일반적인 경우, 점프 서버가 로컬 서버가 된다. 이점에 유의하고 헷갈리지 말자.
jsch.addIdentity(jumpServerIdentityPath);
점프 서버는 Password 대신 key.pem 파일을 사용하여 서버에 접속한다. 따라서 setIdentity()를 사용하여 pem 파일이 저장되어있는 경로를 입력해준다.
jumpSession.setConfig("StrictHostKeyChecking", "no");
StrickHostKeyChecking은 SSH에 처음 연결할 때, 접속하려는 서버의 공개키가 자신의 known_hosts 파일에 저장된 것과 일치하는 지 검사하고 저장할 지 확인하는 옵션이다.
이 옵션을 no로 설정하면 보안에 취약해지므로 신뢰할 수 없는 네트워크에서는 사용을 지양해야한다.
이 예제에서는 각각의 서버가 특정 IP로 제한되어 있기 때문에 해당 옵션을 비활성화 해주었다.
jumpSession.connect();
마지막 코드를 실행시키면 최종적으로 점프 서버로의 SSH 접속이 완료된다.
- Jump Server -> Remote Server
진짜 SSH 터널링을 하는 작업을 진행해보자.
SSH 터널링 / 포트포워딩을 하기 위해 선행되어야하는 작업이 있는데, 바로 사용하지 않는 포트를 확보하는 것이다.
SSH 터널링은 사용되지 않는 포트를 잡아서 localhost:{포트} 로 요청을 받고, 리모트 서버의 특정 포트(여기서는 22)로 포트 포워딩 시키는 방식을 사용하고 있기 때문에 서버 내의 모든 포트가 사용 중이라면 터널링 기능을 사용할 수 없다.
int assigned_port = jumpSession.setPortForwardingL(0, remoteServerHost, 22);
포트 포워딩을 위한 설정 부분이다.
첫번째 인자(Lport)에는 사용 가능한 포트 번호를 적어주면 되는데, "0"을 사용하면 자동으로 서버 내의 남아있는 포트를 찾아 바인딩해준다.
두번째 인자와 세번째 인자에는 터널링을 하고자하는 서버의 host와 port를 작성해주면 된다.
remoteSession = jsch.getSession(remoteServerUser, "localhost", assigned_port);
위에서와 똑같이 리모트 서버로 접속하기 위한 세션을 연다. 이때, 주의할 점은 위에서 포트포워딩 설정을 해주었기 때문에 localhost:{assigned_port} 를 Host에 적어주어야 정상적으로 접속이 가능하다.
remoteSession.setPassword(remoteServerPassword);
이 예제에서 리모트 서버는 Password를 사용하기 때문에 setPassword()를 사용해주었다.
Remote Server에서 Command 실행
// 명령어 실행을 위한 Channel 열기
channel = (ChannelExec) remoteSession.openChannel("exec");
channel.setCommand(request);
channel.setInputStream(null);
InputStream input = channel.getInputStream();
channel.connect();
log.info("channel.isConnected() : {}", channel.isConnected());
// 결과값 가져오기
byte[] buffer = new byte[1024];
while (true) {
int bytesRead = input.read(buffer, 0, buffer.length);
if (bytesRead < 0) {
break;
}
response.append(new String(buffer, 0, bytesRead));
}
이제 접속한 리모트 서버에서 Command를 보낼 수 있게 되었다.
channel = (ChannelExec) remoteSession.openChannel("exec");
channel.setCommand(request);
서버에서 직접 Command를 보내기 위해서는 Channel을 열어주어야한다. 이를 위해 리모트 서버의 세션에서 openChannel()
을 해주어야하는데, 이 메소드의 인자로 들어갈 수 있는 타입 중 주로 사용하는 타입은 다음과 같다.
- session: 기본 세션 채널, 특별한 동작 없음
- shell: 원격 쉘을 열어 명령어 실행. 일반적으로 대화식 작업에 사용
- exec: 단일 명령어 실행. 명령 실행이 완료되면 채널이 자동으로 닫힘.
- sftp: 파일 전송을 위해 사용
이 예제에서는 단순히 커맨드를 실행하기만 하면 되므로 exec타입을 사용했다.
channel.setInputStream(null);
다음으로는 채널 입력 스트림을 설정해주어야한다.
다음과 같은 이유로 입력 스트림은 null로 설정해준다.
- 단방향 통신: 이 예제에서는 서버에 명령을 내리고 그 결과만 읽어오면 된다. 즉, 명령 실행 후에 추가적으로 서버에 보낼 데이터가 없고, 이런 경우 입력 스트림이 필요하지 않다.
- 명령어 독립성: 명령어가 실행되는 동안에는 다른 입력을 받을 필요가 없다. 명령어는 이미
setCommand(request);
로 설정되었기 때문에 추가적인 입력 스트림은 필요하지 않다. - 리소스 절약: 불필요한 스트림을 열지 않음으로써 리소스를 절약할 수 있다.
InputStream input = channel.getInputStream();
channel.connect();
마지막으로 명령어의 출력을 받을 스트림을 가져온 후 채널을 연결하여 명령어를 실행하면 inputStream에서 결과값을 가져와 가공할 수 있는 단계가 된다.
입력 스트림(Input Stream)?
데이터가 프로그램으로 들어오는 통로. 여기서는 SSH 채널에서 명령어의 실행 결과를 읽어오는 데 사용된다.
Session 닫기
finally {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (remoteSession != null && remoteSession.isConnected()) {
remoteSession.disconnect();
}
if (jumpSession != null && jumpSession.isConnected()) {
jumpSession.disconnect();
}
}
위의 코드들이 try-catch로 묶여있었는데, 마지막 finally 키워드를 이용하여 채널과 세션을 모두 닫아준다.
Session은 모든 과정이 끝나더라도 자동으로 연결이 끊어지지 않기 때문에 작업 중간에 에러가 나더라도 세션이 유지된다.
따라서 꼭 세션을 닫아주도록 하자.
마치며
보안이 철저한 서버의 구조가 많은 우리 회사의 특성상 이렇게 SSH 터널링을 한번 구현해두니 다방면으로 사용할 수 있게 되었다. 앞으로는 어플리케이션 서버에서 여러 서버로의 접근이 가능해진 셈이니 말이다.
보안이 중요한 회사 도메인 특성상 여러 단계의 서버를 거칠일이 많기 때문에 기록해두고 자주 꺼내볼듯 싶다.
'Study > Spring boot' 카테고리의 다른 글
[Spring boot] @ModelAttribute에 대한 이해 (2) | 2024.01.20 |
---|---|
[Spring boot] WebClient 사용해보기 - 비동기 통신 subscribe() (0) | 2023.11.11 |
[Spring boot] WebClient 사용해보기 - 모듈화 1-2 (0) | 2023.08.26 |
[Spring boot] WebClient 사용해보기 - 모듈화 1-1 (0) | 2023.08.25 |
[JPA] 프록시(Proxy), 지연 로딩(LAZY Loading), 즉시 로딩(EAGER Loading) (0) | 2022.06.23 |