암호화된 SSH 연결로 포트를 안전하게 전달하기
SSH 터널링이란
SSH 터널링은 SSH의 암호화된 연결 안으로 TCP 연결을 전달하는 기능이다. 방화벽에 데이터베이스나 개발 서버 포트를 직접 공개하지 않고, SSH 접속 권한을 가진 사용자만 내부 서비스에 도달하게 할 수 있다.
터널은 서비스를 새로 만들지 않는다. 한쪽에서 받은 TCP 연결을 SSH 서버를 경유해 지정한 호스트와 포트로 연결한다. HTTP뿐 아니라 PostgreSQL, MySQL, Redis, 원격 데스크톱처럼 TCP를 쓰는 서비스에 적용할 수 있다.
애플리케이션 → 로컬 수신 포트 → 암호화된 SSH 연결 → 목적 서비스UDP는 일반적인 `ssh -L`, `-R`, `-D` 포워딩의 대상이 아니다. UDP가 필요하면 VPN이나 별도 도구를 검토한다.
공통 명령 형식
터널만 만들고 원격 셸을 열지 않을 때는 `-N`을 쓴다. 대화형 터미널도 필요 없으면 `-T`를 더한다.
ssh -N -T user@ssh-server| 옵션 | 의미 |
|---|---|
| `-N` | 원격 명령을 실행하지 않고 포워딩만 한다. |
| `-T` | 가상 터미널을 할당하지 않는다. |
| `-L` | 로컬 포트 포워딩을 만든다. |
| `-R` | 원격 포트 포워딩을 만든다. |
| `-D` | 동적 SOCKS 프록시를 만든다. |
| `-p` | SSH 서버의 포트를 지정한다. |
| `-i` | 사용할 개인키 파일을 지정한다. |
| `-v` | 연결 과정을 자세히 출력한다. `-vv`, `-vvv`로 더 늘릴 수 있다. |
`ssh-server`는 터널의 중계점인 SSH 서버다. 포워딩의 최종 목적지와 같을 수도, 다를 수도 있다.
로컬 포트 포워딩 -L
로컬 포워딩은 내 컴퓨터에 수신 포트를 연다. 그 포트로 연결하면 SSH 서버 쪽에서 목적 서비스로 접속한다.
ssh -N -T \
-L 127.0.0.1:15432:db.internal:5432 \
user@gateway.example.com명령을 네 부분으로 읽는다.
-L [내 수신 주소:]내 포트:목적지 주소:목적지 포트위 예에서는 내 컴퓨터의 `127.0.0.1:15432`에 접속한 트래픽이 SSH 서버 `gateway.example.com`을 지나, SSH 서버가 볼 수 있는 `db.internal:5432`로 간다.
내 DB 도구 → 127.0.0.1:15432 → SSH gateway → db.internal:5432DB 도구에는 실제 내부 주소 대신 로컬 터널 주소를 설정한다.
host=127.0.0.1
port=15432`db.internal`의 DNS 해석과 접속은 내 컴퓨터가 아니라 SSH 서버 쪽에서 이루어진다. SSH 서버에서 그 이름과 포트에 접근할 수 있어야 한다.
로컬 웹 서비스를 열어 보기
원격 서버 자신에서만 열려 있는 관리 화면을 내 브라우저로 볼 수 있다.
ssh -N -T \
-L 127.0.0.1:18080:127.0.0.1:8080 \
user@example.com브라우저에서 `http://127.0.0.1:18080`을 열면, 원격 서버의 `127.0.0.1:8080`으로 전달된다. 두 개의 `127.0.0.1`은 서로 다른 기계를 뜻한다. 첫 주소는 내 컴퓨터의 수신 주소이고, 두 번째는 원격 SSH 서버 관점의 목적지다.
로컬 수신 주소를 생략하거나 `localhost`로 두는 대신 `127.0.0.1`을 명시하면 외부 인터페이스에 실수로 노출되는 일을 줄일 수 있다.
리버스 포트 포워딩 -R
리버스 포워딩은 SSH 서버 쪽에 수신 포트를 만들고, 연결을 SSH 클라이언트 쪽의 서비스로 되돌린다. 외부에서 직접 들어올 수 없는 NAT 뒤 개발 서버를 중계 서버에 연결할 때 쓴다.
ssh -N -T \
-R 127.0.0.1:18080:127.0.0.1:3000 \
user@gateway.example.com형식은 다음과 같다.
-R [원격 수신 주소:]원격 포트:목적지 주소:목적지 포트위 명령은 원격 SSH 서버의 `127.0.0.1:18080`으로 들어온 연결을 SSH 클라이언트가 접근할 수 있는 `127.0.0.1:3000`으로 보낸다.
원격 서버 127.0.0.1:18080 → SSH 터널 → 내 컴퓨터 127.0.0.1:3000리버스 터널의 마지막 목적지 `127.0.0.1:3000`은 SSH 클라이언트가 실행된 컴퓨터의 로컬 서비스다. 터널 명령을 컨테이너 안에서 실행했다면 그 컨테이너의 localhost일 수 있으므로 네트워크 경계를 확인한다.
Nginx와 리버스 SSH 터널 연결
원격 중계 서버의 Nginx가 리버스 터널 포트로 요청을 전달하게 할 수 있다.
server {
listen 443 ssl;
server_name dev.example.com;
location / {
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}흐름은 다음과 같다.
브라우저 → Nginx → 원격 127.0.0.1:18080
→ 리버스 SSH 터널 → 개발 PC 127.0.0.1:3000Nginx가 같은 원격 서버에서 실행된다면 터널 포트를 `127.0.0.1`에만 열어도 된다. 외부에 터널 포트를 직접 공개할 이유가 없다. TLS와 접근 제어는 Nginx에서 적용한다.
원격 포트를 외부에 공개할 때
다른 컴퓨터가 원격 포트에 직접 접속해야 한다면 원격 수신 주소를 지정할 수 있다.
ssh -N -T \
-R 0.0.0.0:18080:127.0.0.1:3000 \
user@gateway.example.com그러나 SSH 서버 설정의 `GatewayPorts`가 허용해야 실제 외부 인터페이스에 바인딩된다.
GatewayPorts clientspecified이 설정은 노출 범위를 크게 넓힌다. 방화벽, 인증, 서비스 자체 보안을 모두 검토해야 한다. 특별한 이유가 없다면 원격 포트를 `127.0.0.1`에만 열고 Nginx 같은 인증된 진입점을 앞에 둔다.
동적 포트 포워딩 -D
동적 포워딩은 내 컴퓨터에 SOCKS 프록시를 만든다. 목적지 호스트와 포트는 SSH 명령에 하나로 고정하지 않고, 프록시를 사용하는 애플리케이션이 요청마다 정한다.
ssh -N -T \
-D 127.0.0.1:1080 \
user@gateway.example.com브라우저나 명령줄 도구의 SOCKS5 프록시를 `127.0.0.1:1080`으로 설정한다.
curl --proxy socks5h://127.0.0.1:1080 https://example.com/`socks5h`의 `h`는 호스트 이름 해석도 프록시 쪽에서 하라는 뜻이다. `socks5`를 쓰면 DNS 질의가 로컬 네트워크로 나가 목적지 이름이 노출되거나, 내부 이름을 해석하지 못할 수 있다.
동적 터널은 HTTP 프록시가 아니라 SOCKS 프록시다. 애플리케이션이 SOCKS를 지원해야 한다. 모든 운영체제 트래픽이 자동으로 터널을 통과하는 VPN과도 다르다.
점프 호스트 -J
목적 서버에 직접 SSH할 수 없고 중간 서버를 거쳐야 할 때 ProxyJump를 쓴다.
ssh -J user@gateway.example.com app@10.0.0.20터널과 함께 사용할 수도 있다.
ssh -N -T \
-J user@gateway.example.com \
-L 127.0.0.1:15432:127.0.0.1:5432 \
app@10.0.0.20최종 SSH 연결은 `10.0.0.20`에 있고, 목적지 `127.0.0.1:5432`는 최종 서버의 관점이다. 오래된 `ssh -t gateway ssh target` 연결보다 ProxyJump가 키 전달과 표준 입출력 처리를 명확히 한다.
~/.ssh/config로 명령 줄이기
반복하는 설정은 SSH 설정 파일에 둔다.
Host app-gateway
HostName gateway.example.com
User deploy
IdentityFile ~/.ssh/id_ed25519_app
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes
Host app-db-tunnel
HostName gateway.example.com
User deploy
IdentityFile ~/.ssh/id_ed25519_app
LocalForward 127.0.0.1:15432 db.internal:5432
ExitOnForwardFailure yes이제 다음처럼 실행할 수 있다.
ssh -N -T app-db-tunnel설정 파일과 개인키 권한은 다른 사용자가 읽지 못하게 제한한다.
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config ~/.ssh/id_ed25519_app개인키 자체를 서버나 저장소에 복사하지 않는다. 공개키만 대상 계정의 `authorized_keys`에 등록한다.
터널 생성 실패를 즉시 알기
포워딩 포트를 열지 못해도 SSH 세션 자체는 유지될 수 있다. 자동화에서는 실패를 즉시 반환하게 한다.
ssh -N -T \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:15432:db.internal:5432 \
user@gateway.example.com`ExitOnForwardFailure`는 수신 포트 생성 실패를 잡는다. 최종 목적 서비스가 나중에 다운되는 것까지 시작 시점에 보장하지는 않는다. 별도의 상태 확인이 필요하다.
백그라운드 실행 옵션 `-f`는 인증 뒤 SSH 자신을 백그라운드로 보낸다.
ssh -f -N -T \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:15432:db.internal:5432 \
user@gateway.example.com프로세스 관리가 필요하면 임의로 `&`에 맡기기보다 systemd나 launchd가 재시작과 로그를 담당하게 한다.
연결 유지 설정
NAT와 방화벽은 한동안 트래픽이 없는 연결을 지울 수 있다. SSH 수준의 keepalive로 끊김을 감지한다.
ssh -N -T \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o TCPKeepAlive=yes \
-R 127.0.0.1:18080:127.0.0.1:3000 \
user@gateway.example.com30초마다 확인하고 세 번 응답이 없으면 종료한다. 종료된 뒤 다시 연결하는 일은 SSH 자체가 아니라 서비스 관리자나 autossh가 맡는다.
무조건 빠른 재연결 루프를 만들면 인증 실패나 네트워크 장애 중에 서버를 계속 두드린다. 재시도 간격과 상한을 둔다.
systemd로 터널 관리하기
Linux에서는 터널을 서비스로 등록할 수 있다. 아래는 개념 예시다.
[Unit]
Description=Reverse SSH tunnel
After=network-online.target
Wants=network-online.target
[Service]
User=tunnel
ExecStart=/usr/bin/ssh -N -T \
-o BatchMode=yes \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R 127.0.0.1:18080:127.0.0.1:3000 \
app-gateway
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target서비스 계정의 `~/.ssh/config`, known_hosts, 키 권한이 맞아야 한다. `BatchMode=yes`는 비밀번호 입력을 기다리지 않고 실패하게 한다. 자동 서비스에 사용자 개인키를 재사용하기보다 용도가 제한된 전용 키와 계정을 만든다.
authorized_keys에서 키 권한 제한하기
터널 전용 공개키가 원격 셸을 실행하지 못하도록 제한할 수 있다. 서버의 `authorized_keys`에서 해당 키 앞에 옵션을 붙인다.
restrict,port-forwarding,permitlisten="127.0.0.1:18080" ssh-ed25519 AAAA... tunnel-keyOpenSSH 버전에 따라 지원 옵션이 다르므로 서버의 `sshd_config(5)`와 `authorized_keys(5)` 문서를 확인한다. `restrict`가 포워딩까지 막으면 필요한 `port-forwarding`을 명시적으로 다시 허용한다.
로컬 포워딩의 최종 목적지를 제한하려면 `permitopen`, 리버스 포워딩의 수신 주소를 제한하려면 `permitlisten`을 검토한다. 제한 없이 터널 계정을 주면 그 계정이 서버에서 접근 가능한 내부망으로 우회하는 통로가 될 수 있다.
서버 전체 설정에서도 제한할 수 있다.
AllowTcpForwarding yes
GatewayPorts no
PermitOpen db.internal:5432필요한 사용자나 Match 블록에만 적용하는 편이 안전하다. 설정 변경 전 `sshd -t`로 검사하고 기존 접속을 유지한 채 새 세션으로 확인한다.
호스트 키 검증
처음 연결할 때 SSH는 서버 호스트 키를 known_hosts에 기록한다. 다음 연결에서 키가 바뀌면 중간자 공격 또는 서버 재설치 가능성을 경고한다.
자동화에서 다음처럼 검증을 꺼서는 안 된다.
StrictHostKeyChecking=no
UserKnownHostsFile=/dev/null편하다는 이유로 호스트 검증을 없애면 암호화된 연결이 공격자에게 끝날 수 있다. 서버의 호스트 키 지문을 별도 신뢰 경로로 확인해 known_hosts에 배포한다. 새 서버를 자동 수용하는 `accept-new`도 기존 키 변경은 거부하지만, 최초 연결의 신뢰 문제는 남는다.
여러 포워딩을 한 연결에 넣기
SSH 연결 하나에 `-L`과 `-R`을 여러 개 넣을 수 있다.
ssh -N -T \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:15432:db.internal:5432 \
-L 127.0.0.1:16379:cache.internal:6379 \
-R 127.0.0.1:18080:127.0.0.1:3000 \
user@gateway.example.com한 연결이 끊기면 모든 포워딩이 함께 끊긴다. 장애 범위를 분리해야 하면 중요도에 따라 별도 서비스로 나눈다.
터널 확인 방법
로컬 또는 원격에서 수신 포트가 열렸는지 확인한다.
ss -ltnp | rg ':15432|:18080'macOS에서는 `lsof -nP -iTCP:15432 -sTCP:LISTEN`을 사용할 수 있다. 그다음 최종 서비스 프로토콜로 확인한다.
curl --fail http://127.0.0.1:18080/health연결이 안 되면 `ssh -vvv` 로그를 보고 다음 순서로 나눈다.
- SSH 서버에 자체 접속이 되는가.
- 요청한 수신 포트를 다른 프로세스가 이미 쓰는가.
- SSH 서버가 포워딩을 허용하는가.
- 목적지 주소는 어느 쪽 관점이며 그 기계에서 접근 가능한가.
- 원격 외부 바인딩이라면 GatewayPorts와 방화벽이 허용하는가.
- 서비스가 실제로 해당 주소와 포트에서 수신 중인가.
`Connection refused`는 목적지에 수신 프로세스가 없거나 잘못된 주소일 때 흔하다. `administratively prohibited`는 SSH 서버 정책이 포워딩을 막을 때 나타날 수 있다. `remote port forwarding failed`는 원격 포트 충돌이나 바인딩 권한을 확인한다.
보안 점검
- 터널 수신 주소는 기본적으로 `127.0.0.1`에 묶는다.
- 데이터베이스와 Redis 포트를 인터넷에 직접 공개하지 않는다.
- 용도별 전용 SSH 키와 최소 권한 계정을 만든다.
- 개인키에 암호를 사용하고, 무인 서비스에는 제한된 별도 키를 쓴다.
- known_hosts 검증을 끄지 않는다.
- 원격 서버의 `AllowTcpForwarding`, `PermitOpen`, `PermitListen`, `GatewayPorts`를 검토한다.
- 터널을 통해 접근한 서비스에도 자체 인증과 권한 검사를 유지한다.
- 터널 프로세스, 실패 횟수, 최종 서비스 상태를 감시한다.
SSH 터널의 명령은 짧지만 방향을 잘못 이해하면 내부 서비스를 외부에 노출할 수 있다. `-L`은 내 로컬 포트에서 원격 쪽 목적지로, `-R`은 원격 포트에서 내 쪽 목적지로, `-D`는 내 로컬 SOCKS 포트에서 요청별 목적지로 간다고 읽으면 된다.
댓글
0개아직 댓글이 없습니다.