RSS
웹 인프라

SSH 리버스 터널: 연결은 안에서 밖으로, 요청은 밖에서 안으로

작성자
듀오랩스 대표·7분 읽기

서버가 두 대 있습니다. dev는 3000번 포트에서 앱이 돌고 있는 개발 서버이고, gate는 사람들이 접속하는 서버입니다. 목표는 gate의 4000번을 열면 dev의 3000번 화면이 보이게 하는 것입니다. 이걸 만드는 SSH 명령이 둘 있습니다.

# gate 에서 실행
ssh -L 4000:localhost:3000 dev

# dev 에서 실행
ssh -R 4000:localhost:3000 gate

결과는 같습니다. 브라우저는 gate:4000을 열고 dev:3000의 응답을 받습니다. 달라진 것은 SSH 연결을 누가 먼저 맺었느냐 하나입니다. 첫 번째는 gate가 dev의 22번 포트로 들어가고, 두 번째는 dev가 gate의 22번 포트로 나갑니다.

dev 앞의 방화벽이 들어오는 연결을 막고 있으면 첫 번째만 실패합니다. 이 차이를 가리키는 이름이 리버스 터널입니다.

"리버스"가 뒤집는 것은 데이터가 아니라 연결의 시작점

이름만 보면 데이터가 거꾸로 흐르는 별도 기술처럼 들립니다. 그렇게 이해하면 두 명령이 왜 같은 결과를 내는지 설명이 안 됩니다. 터널이 한 번 열리고 나면 어느 쪽이 연결을 맺었든 바이트는 양방향으로 오갑니다.

뒤집히는 것은 연결을 시작하는 쪽입니다. 보통은 서비스를 쓰려는 쪽이 서비스가 있는 쪽으로 먼저 연결합니다. 리버스 터널에서는 서비스를 내놓는 쪽이 중개 서버로 먼저 연결해 두고, 이후 도착하는 요청을 그 연결로 받습니다. 저는 "연결 방향이 보통과 반대"라는 뜻으로 읽는 것이 가장 정확하다고 봅니다.

포트가 열리는 곳은 같고, 연결을 거는 쪽만 다르다

ssh(1) 매뉴얼은 -R을 이렇게 설명합니다. 원격(서버) 쪽의 지정한 포트로 오는 연결을 로컬 쪽으로 넘긴다는 것입니다. 소켓을 여는 곳은 SSH 서버, 이 예에서는 gate입니다. -L은 SSH를 실행한 쪽에 포트를 엽니다. 위 예에서는 그 쪽도 gate입니다.

-L (gate에서 실행) -R (dev에서 실행)
4000번을 여는 곳 gate gate
먼저 TCP 연결을 맺는 쪽 gate가 dev로 dev가 gate로
dev 방화벽에 필요한 규칙 22번 인바운드 허용 아웃바운드만
사용자 요청이 도착하는 길 gate에서 dev로 같은 연결을 거슬러 dev로

방화벽은 안에서 나간 연결 하나만 봅니다

상태를 추적하는 방화벽은 대개 안쪽에서 먼저 나간 연결의 응답과 그 연결 위의 트래픽을 통과시킵니다. 리버스 터널은 이 성질을 씁니다. dev가 gate로 나가는 연결을 하나 맺어 두면, 바깥에서 온 요청도 방화벽 입장에서는 그 연결의 일부입니다. 새로 들어오는 연결이 없으니 막을 것이 없습니다.

방어하는 쪽에서는 이게 불편한 점이기도 합니다. 안쪽에서 파 둔 구멍이라 방화벽 규칙으로는 그 위에 무엇이 흐르는지 알 수 없습니다. 제 판단으로는 이런 터널을 허용할 때 어느 서버가 어느 서버로 나갈 수 있는지를 목록으로 두고, 그 키로는 터널 외에 다른 일을 못 하게 제한해 두는 쪽이 맞습니다.

열린 포트의 주소가 입구의 범위를 정한다

-R로 열린 포트는 기본적으로 gate 자신에게만 보입니다. sshd_config(5)의 GatewayPorts 기본값이 no여서 원격 포트 포워딩이 루프백 주소에 묶이기 때문입니다. 다른 기기에서도 접속하게 하려면 yes나 clientspecified로 바꾸는 방법이 있습니다.

저는 그 설정을 건드리지 않는 쪽을 선호합니다. gate에 리버스 프록시를 하나 두고 바깥 주소로 받은 요청을 127.0.0.1:4000으로 넘기게 하면, 사내에서 보이는 입구가 프록시 하나로 줄어듭니다. 인증서도 거기서 처리할 수 있습니다.

터널은 끊어지고, 실패는 조용합니다

gate의 4000번을 다른 프로세스가 이미 쓰고 있으면 어떻게 될까요. SSH 연결 자체는 성공하고 포워딩만 설정되지 않은 채 명령이 계속 살아 있을 수 있습니다. ssh_config(5)의 ExitOnForwardFailure는 기본값이 no입니다. 터널용 명령에는 -o ExitOnForwardFailure=yes를 붙여야 포트를 못 열었을 때 바로 종료됩니다.

연결이 중간에 끊기는 경우도 있습니다. ServerAliveInterval로 죽은 연결을 알아채게 하고, 종료되면 다시 거는 일은 systemd나 autossh 같은 쪽에 맡깁니다. 이 부분은 SSH가 해 주지 않습니다.

같은 구조가 HTTP 층에 올라가면

Cloudflare Tunnel도 같은 방향 구조입니다. 서버에서 cloudflared가 Cloudflare로 나가는 연결을 먼저 맺고, 도메인으로 들어온 요청이 그 연결을 거슬러 서버로 전달됩니다. Cloudflare 문서는 이 연결을 outbound-only로 설명합니다. (Cloudflare Tunnel 문서)

다른 점은 계층입니다. SSH는 TCP 포트를 그대로 넘기고, Cloudflare는 HTTP를 이해하는 프록시로서 도메인과 인증서를 함께 맡습니다. 공개 호스트명에 연결한 서비스는 인터넷에서 열린다는 점을 기억해야 하고, 접근 제한은 Cloudflare Access 같은 별도 설정으로 겁니다. 사내에서만 쓰려는 개발 서버라면 SSH 리버스 터널 뒤에 사내 프록시를 두는 구성이 노출 범위를 더 좁게 잡기 쉽다고 생각합니다.

여기서부터는 환경마다 다릅니다

-L과 -R의 동작, GatewayPorts와 ExitOnForwardFailure의 기본값은 OpenSSH 매뉴얼에 적힌 그대로입니다. (ssh(1), sshd_config(5), ssh_config(5))

방화벽이 안쪽에서 나간 연결을 어떻게 다루는지는 장비와 설정에 따라 다릅니다. 아웃바운드를 이미 목록으로 제한한 환경이라면 리버스 터널도 막힙니다. 클라우드 터널 제품들의 세부 동작은 제가 Cloudflare 문서 밖에서는 확인하지 않았습니다.

우리 서비스에 적용할 계획이 있으신가요?

클라우드 구축·배포 운영 살펴보기 →

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.