Self-hosted HTTP tunnels with SSH and nginx
SSH와 nginx로 직접 운영하는 HTTP 터널
OpenSSH의 원격 포트 포워딩과 nginx를 조합해 로컬 웹 서비스를 공개하는 자체 호스팅 터널을 구현합니다. nginx의 secure_link로 만료 시간이 있는 접근 토큰을 검사하고, 셸 스크립트가 OpenSSH가 할당한 포트와 공유 URL을 찾아 출력합니다.
- 주제
AI 요약
로컬에서만 실행 중인 웹 서비스의 미리보기를 다른 사람에게 보여주려면 터널링 도구를 쓸 수 있습니다. 이 글은 별도 상용 서비스나 전용 클라이언트 없이 OpenSSH와 nginx만으로 직접 운영하는 방법을 설명합니다. 사용자는 ssh -R 0:localhost:8080 http-over-ssh를 실행하고, 서버는 원격 포트를 하나 할당합니다. 예시에서는 41535번 포트를 받아 로컬의 8080번 서비스로 연결합니다.
원격 포트와 nginx 연결
OpenSSH에서 원격 포트를 0으로 지정하면 서버가 사용 가능한 포트를 골라 줍니다. nginx는 p와 다섯 자리 포트 번호로 구성된 호스트 이름을 정규식으로 처리합니다. 예를 들어 p41535.ssh.luffy.cx로 들어온 요청을 127.0.0.1:41535에 전달합니다. 이를 위해 *.ssh.luffy.cx DNS 레코드와 Let’s Encrypt 와일드카드 인증서도 설정합니다. 글쓴이는 DNS-01 챌린지에 Route 53을 사용하며 NixOS에서 인증서를 자동으로 발급합니다.
만료 토큰으로 접근 제어
포트 번호만 알면 서비스에 접근할 수 있는 구성은 충분한 보호가 되지 않습니다. 글쓴이는 nginx의 ngx_http_secure_link_module을 이용해 URL에 만료 시간이 포함된 토큰을 추가합니다. 토큰은 만료 시각, 포트 번호, 비밀 문자열을 이어 붙여 MD5 해시를 만들고 Base64로 인코딩한 값입니다. Base64에 포함될 수 있는 문자를 URL 호스트 이름에 넣으면 대소문자를 구분하지 않는 도메인 특성과 맞지 않으므로, 토큰을 URL의 사용자 이름 자리에 둡니다. 예시는 https://토큰--만료시각@p41535.ssh.luffy.cx/ 형태입니다. HTTP 클라이언트는 사용자 이름을 Basic Authentication 정보로 보내며, nginx는 이를 $remote_user에서 읽습니다.
nginx의 map 지시문은 사용자 이름에서 토큰과 만료 시각을 분리합니다. secure_link_md5는 만료 시각, 포트, 비밀 문자열을 기준으로 요청의 해시를 검사합니다. 해시가 없거나 맞지 않으면 401과 WWW-Authenticate 헤더를 반환하고, 해시가 맞더라도 링크가 만료됐으면 410을 반환합니다. 유효한 요청은 해당 포트로 프록시합니다. 전달 과정에서는 Authorization 헤더를 제거하고 Host, X-Forwarded-For를 설정합니다. WebSocket 연결을 위해 HTTP/1.1과 Upgrade, Connection 헤더도 설정하며, 버퍼링을 끄고 프록시 읽기 시간 제한을 30분으로 둡니다.
SSH가 할당한 포트 찾기
사용자가 매번 토큰을 직접 만들 필요는 없습니다. 서버의 헬퍼 스크립트가 SSH 세션을 유지하면서 공유 URL을 출력합니다. OpenSSH는 동적으로 할당한 포트를 환경 변수로 제공하지 않으므로, 스크립트는 현재 프로세스부터 부모 프로세스를 따라가며 sshd-session 프로세스를 찾습니다. 이어 sudo ss로 해당 프로세스가 사용하는 리스닝 포트를 조회합니다. 포트를 찾으면 현재 시각에 24시간을 더해 만료 시각을 계산하고, 포트와 비밀 문자열을 넣어 토큰을 생성합니다. 마지막에는 URL을 출력하고 sleep infinity로 SSH 세션이 종료되지 않게 합니다.
서버에는 이 스크립트를 http-over-ssh라는 명령으로 설치하고, SSH 설정 파일에서 호스트 별칭에 RemoteCommand http-over-ssh를 지정합니다. 사용자는 ssh -R 0:localhost:8080 http-over-ssh 한 번으로 터널을 열고 URL을 받습니다.
토론에서 나온 만료·재사용 문제
댓글에서는 이전 터널의 토큰이 새 터널에 재사용될 수 있는지 질문합니다. 글쓴이는 같은 포트를 다시 쓰면 기존 토큰도 여전히 유효하다고 답합니다. nginx는 새 SSH 세션이 시작됐는지 알지 못하기 때문입니다. Unix 소켓으로 전달하면 구분할 수 있지만, 사용자가 고유한 소켓 경로를 지정해야 합니다. SSH 세션에서 nginx를 실행하는 방식도 대안으로 언급하며, 이 경우 터미널에서 접근 로그를 확인할 수 있습니다.
원문: Vincent Bernat / 번역·요약: Trawling
Lobsters 반응
- @girlonthemoon — 꽤 괜찮네요. nginx를 이런 식으로 쓸 생각은 못 했습니다. 저는 nginx보다 모든 곳에서 Caddy를 쓰지만, 아마 그래서 그런 것 같습니다. pico.sh 구독이 있으니 설정한다면 tuns.sh 서비스를 쓸 것 같지만, tuns에 접근할 수 없었다면 이걸 설정하려고 노력했을 것 같습니다.
- @tuxes — 이미 있는 도구를 조합해 문제를 푸는 방식이 마음에 듭니다. nginx 인증 로직은 따라가기 조금 까다롭지만, 생성된 URL에 24시간 동안 유효한 예전 방식의 MAC이 들어가고 그 값으로 포트를 인증하는 것 같습니다. 포트를 재사용해 새 터널을 열면 이전 터널의 토큰이 새 터널에서도 유효한 상태로 남는 문제는 어떻게 막나요?
- @vbernat — 네, 기존 토큰은 계속 유효합니다. nginx는 SSH 세션이 달라졌다는 사실을 알 방법이 없습니다. 가끔은 포트에
0대신 특정 번호를 지정해 이전 포트를 재사용합니다. Unix 소켓으로 포워딩할 수도 있지만 사용자가 ‘고유한’ 경로를 제공해야 합니다. OpenSSH는 추상 Unix 소켓을 지원하지 않는 것 같아서 전체 경로도 지정해야 합니다. - @tuxes — 터널을 만든 쪽과 터널을 쓰는 쪽이 모두 어느 정도 신뢰받는 일반적인 상황이라면 위험은 꽤 낮을 것 같습니다. Unix 소켓으로 포워딩할 수도 있지만 사용자가 ‘고유한’ 경로를 제공해야 합니다. 터널 서비스가 사용자가 직접 고유한 문자열을 정하는 대신 UUID를 만들면 어떨까요? 다만 사용자가 고유한 문자열을 제공한다면, 터널을 만든 쪽이 같은 문자열로 터널을 다시 열었을 때 재부팅 뒤에도 기존 토큰이 유효하게 남을 것 같습니다.
- @vbernat — 그러려면 서버 쪽에서 OpenSSH 외의 무언가가 필요합니다. 또는 포워딩 nginx를 SSH 세션에서 명령으로 실행할 수도 있습니다. 그렇게 하면 터미널에서 접근 로그도 볼 수 있습니다.
- @vbernat — 네, 기존 토큰은 계속 유효합니다. nginx는 SSH 세션이 달라졌다는 사실을 알 방법이 없습니다. 가끔은 포트에
- @creesch — 저도 얼마 전에 autossh로 비슷한 걸 해봤습니다. 인증 방식은 다르고, 특정 항목만 노출하려고 지속적인 단일 터널을 만드는 목적이었습니다. 용도는 조금 다르지만, 이 방식이라면 사용자와 SSH 키에도 비슷한 제한을 두겠습니다. 제가 쓴 방법은 VPS에 전용 사용자를 만들고 SSH 키에
restrict,port-forwarding,permitlisten="127.0.0.1:8080"을 설정한 뒤, autossh로127.0.0.1:8080에 포워딩하는 방식입니다. 인증은 Caddy의basic_auth로 처리했습니다. 서비스를 공개적으로 노출하고 싶지 않았고 저만 쓰는 용도였습니다. autossh는 Unraid NAS에서 쉽게 실행할 수 있도록 Docker 컨테이너로 감쌌습니다. 테스트라면 직접 실행해도 됩니다. - @hwj — OpenBSD에서는 PF 규칙으로 nginx를 생략할 수 있습니다.
pass in proto tcp to port 8080 divert-to 127.0.0.1 port 2441 label "self-hosted http"입니다. 다른 BSD나 Linux의 netfilter에서도 작동할 것 같습니다. - @loige — 이런 방식이 좋습니다. 사용 사례마다 전용 소프트웨어가 필요하다고 생각하곤 하지만, 한 가지 일을 잘하는 작은 소프트웨어를 쉽게 조합할 수 있다는 Unix 철학을 잊곤 합니다. 좋은 사례와 깔끔한 해결책을 공유해 주셔서 감사합니다.