SSH 리버스 터널: 연결은 안에서 밖으로, 요청은 밖에서 안으로
서버가 두 대 있습니다. 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 문서 밖에서는 확인하지 않았습니다.
우리 서비스에 적용할 계획이 있으신가요?
클라우드 구축·배포 운영 살펴보기 →함께 읽기
- 역방향 SSH 터널이 반쯤 죽을 때: ServerAlive 가 8시간 동안 못 끊은 이유역방향 SSH 터널로 오리진을 내보내는 구성에서, 터널 너머의 서비스들이 한꺼번에 「응답 없음」이 된 날의 기록입니다. 프로세스도 소켓도 살아 있다고 말하는데 서비스만 죽어 있었고, 원인은 제가 「죽은 연결은 90초 안에 정리된다」고 믿고 있던 설정에 있었습니다.
- 도메인 하나 옮기면서 배운 것들: 이관, 메일, 그리고 터널 이중화의 착각새로 산 도메인을 세팅할 일이 생겼다. 이왕 하는 김에 계정부터 완전히 새로 만들어서 처음부터 끝까지 직접 해보기로 했다. 기존 설정을 복사해오는 대신 하나씩 이해하면서 가는 쪽으로.
- ingress 와 egress: 터널 설정과 청구서에서 만난 두 방향하루에 두 번 이 단어들을 만났습니다. 오전에는 터널 설정 화면에서 ingress 를 봤고, 오후에는 데이터베이스 청구 항목에서 egress 를 봤습니다. 몇 년째 둘 다 쓰고 있었는데, 그날에서야 같은 축의 양쪽이라는 게 눈에 들어왔습니다.
- SSL 인증서를 처음 이해할 때 알아야 할 것웹사이트 주소 앞에 자물쇠 표시가 붙고 https://로 시작한다면 SSL/TLS 인증서가 사용되고 있다는 뜻입니다. 인증서는 방문자가 접속한 사이트가 진짜인지 확인하고, 주고받는 데이터를 암호화하는 역할을 합니다.
- HSTS 설정 전 알아야 할 옵션과 안전한 적용 순서HSTS(HTTP Strict Transport Security)는 브라우저에 “이 도메인은 항상 HTTPS로만 접속해야 한다”고 알려주는 보안 정책입니다. 잘 설정하면 중간자 공격과 SSL stripping을 줄일 수 있지만, 성급하게 적용하면 장애 복구가 어려워질 수 있습니다.