TL;DR

  • OpenSSH의 원격 포트 포워딩과 nginx를 조합하면 별도 터널 서비스 없이 로컬 웹 서비스를 공개하는 자체 호스팅 HTTP 터널 구축이 가능함.
  • 원격 포트를 0으로 지정하면 sshd가 사용 가능한 포트를 할당하며, nginx가 p<포트>.ssh.luffy.cx 요청을 해당 포트로 프록시함.
  • 와일드카드 DNS 레코드와 Let’s Encrypt 인증서가 필요하며, Nginx의 ngx_http_secure_link_module로 만료 시각과 비밀값을 포함한 링크를 검증함.
  • 인증 실패 시 401, 만료 링크에는 410을 반환하고, 프록시 요청에서 Authorization 헤더를 제거하며 WebSocket 연결도 처리함.
  • 보조 스크립트는 sshd-session 프로세스에 연결된 포트를 찾아 인증 토큰을 포함한 URL을 출력하고 SSH 세션을 유지함.

기본 설정

  • 로컬 서비스가 localhost:8080에서 실행되는 경우, ssh -N -R 0:localhost:8080 web02.luffy.cx로 원격 서버의 임의 포트를 통해 연결을 포워딩함. 원격 포트에 0을 지정하면 서버가 사용 가능한 포트를 할당함.
  • nginx는 https://p41535.ssh.luffy.cx 같은 요청을 http://127.0.0.1:41535로 프록시하며, 서버 이름에서 포트 번호를 추출함.
  • *.ssh.luffy.cx 와일드카드 DNS 레코드와 Let’s Encrypt 와일드카드 인증서가 필요함. *.ssh.luffy.cx는 web02.luffy.cx를 가리키고, 인증 기관 제한 및 DNS-01 인증을 위한 CNAME 레코드도 설정함.
  • acme.luffy.cx는 Route 53에서 호스팅되는 DNS 영역이며, 와일드카드 인증서와 여러 웹 서버가 제공하는 도메인의 ACME DNS-01 챌린지에 사용됨. 이 설정에서는 NixOS가 인증서를 자동으로 가져옴.

접근 제어

  • 기본 구성에서는 포트 번호가 콘텐츠를 기밀로 유지하는 유일한 ‘비밀’임. 다른 포워딩 솔루션은 가능한 값을 열거하지 못하도록 도메인 이름에 임의 문자열을 추가하기도 함.
  • ngx_http_secure_link_module은 비밀값을 포함한 여러 값의 해시를 계산해 요청의 해시와 비교함. 해시는 대소문자를 구분하지 않는 도메인 이름에 넣을 수 없는 Base64 문자열이므로, 만료 시각과 함께 URL의 사용자 이름에 포함함.
  • 예시 링크 https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog에서 사용자 이름은 해시와 만료 시각을 담고, 호스트 이름은 포트를, 경로는 요청 경로를 나타냄.
  • 클라이언트는 HTTP 기본 인증을 사용해 사용자 이름을 서버에 전송하며, curl을 포함한 대부분의 HTTP 클라이언트에서 작동함. Nginx는 이를 $remote_user 변수로 제공하고, map 지시문으로 해시와 만료 시각을 추출해 모듈이 요구하는 쉼표 구분 형식으로 결합함.
  • 해시 계산에는 만료 시각, 포트, 비밀값이 사용됨. 예시의 비밀값은 ZuPerS3cr3!임.
  • 검증 결과는 $secure_link에 저장됨.
  • 해시가 일치하지 않으면 빈 값임.
  • 해시는 일치하지만 링크가 만료됐으면 0임.
  • 유효한 링크이면 1임.
  • 해시가 없거나 올바르지 않으면 WWW-Authenticate 헤더와 함께 401을 반환하고, 만료된 링크에는 410을 반환함. 프록시로 요청을 전달하기 전에 Authorization 헤더를 제거하고 WebSocket 연결을 위한 지시문도 설정함.
  • 전체 Nginx 설정은 사용자 이름에서 해시와 만료 시각을 추출하고, secure_link로 검증한 뒤 요청을 해당 포트로 전달함. 이때 Host와 X-Forwarded-For 헤더를 설정하고, HTTP/1.1 및 WebSocket 업그레이드를 활성화하며, 프록시 버퍼링을 끄고 읽기 제한 시간을 30분으로 설정함.

보조 스크립트

  • OpenSSH가 할당한 임시 포트는 환경 변수에 나타나지 않으므로, 스크립트는 상위 프로세스를 살펴 sshd-session 프로세스를 찾음. SSH 세션이 아니면 오류를 출력하고 종료함.
  • ss 명령으로 해당 sshd-session 프로세스와 연결된 TCP 리스닝 포트를 찾음. 포트가 없으면 ssh -R 0:localhost:PORT를 사용하라는 오류를 출력하고 종료함.
  • 스크립트는 수명을 86,400초로 설정하고 만료 시각, 포트, 비밀값으로 MD5 해시를 계산함. 해시는 Base64로 인코딩한 뒤 URL에 사용할 수 있도록 문자와 패딩을 변환함.
  • 각 포트에 대해 https://<토큰>--<만료 시각>@p<포트>.ssh.luffy.cx/ 형식의 URL을 출력한 뒤 sleep infinity로 SSH 세션을 유지함.
  • 서버에 스크립트를 http-over-ssh로 설치하고, SSH 설정에서 호스트 별칭 http-over-ssh에 서버 이름과 원격 명령을 지정하며 ControlPath none을 설정함.
  • 이 구성은 서버에서 이미 실행 중인 OpenSSH와 nginx만 사용하며, 짧은 명령으로 자체 호스팅 터널과 공유 URL을 제공함. NixOS 환경에서는 http-over-ssh.nix 설정도 사용할 수 있음.