내 도메인으로 터널링을 구축할 수 있을까? — Synology NAS에 ktunnel을 만든 과정

로컬 포트가 ktunnel 클라이언트와 Synology NAS의 relay를 거쳐 HTTPS 공개 주소로 연결되는 구조

작성자

· 카테고리:

ngrok과 Cloudflare Tunnel을 사용해봤다. 로컬 포트를 외부에 공개하는 데는 도움이 됐지만, 내가 원하는 방식으로 계속 운영하기에는 제약이 있었다. 이미 내 도메인이 있고 늘 켜져 있는 Synology NAS도 있는데, 그렇다면 터널링 서버를 내 NAS에 직접 구현할 수 있을까? 이 질문에서 ktunnel이 시작됐다.

처음에는 터널만 연결되면 충분하다고 생각했다. 그런데 다른 사람에게 사용하게 하고, 공개 저장소에 코드를 올리고, NAS에서 계속 운영하려고 하자 질문이 달라졌다. 공유 비밀키를 계속 써도 되는가. 중계 서버의 인증서를 정말 검증하고 있는가. 업데이트 파일을 믿어도 되는가. 인증서가 갱신된 뒤에도 relay가 새 파일을 읽는가.

그 질문을 따라 Go와 frp로 만든 ktunnel을 Synology NAS에 배포하면서, 연결되는 개인 도구를 다른 사람이 사용해도 되는 운영 서비스로 바꿔 간 기록이다. 공개 기준은 ktunnel v0.6.0과 2026년 9월 10일의 저장소 상태다.

결론부터 정리하면

  • 답: 가능했다. 내 도메인과 Synology NAS를 이용해 로컬 포트를 https://name.example.com 형태의 외부 HTTPS 주소로 공개하는 ktunnel을 만들었다.
  • 환경: Synology NAS x86_64, Docker, nginx, SQLite WAL, 비root·read-only 컨테이너, frp v0.71.0을 사용했다.
  • 구성: ktunnel 클라이언트, ktunneld 제어 서버, frp relay, nginx 공개 HTTPS 경계로 나눴다.
  • 인증: 공유 비밀키 대신 사용자별 토큰을 발급·폐기하고, DB에는 원문 대신 해시만 저장했다.
  • 보안: v0.6부터 relay 인증서의 체인과 호스트 이름을 확인하고, 릴리스와 frps 파일의 checksum을 검증한다.
  • 검증: Go 테스트·race·vet, 설치·checksum·인증서 롤백 테스트와 실제 HTTPS 응답·TLS 실패 경로를 확인했다.
  • 운영: DSM 작업 스케줄러로 인증서 갱신 뒤 frps를 다시 읽게 하고, health check 실패 시 이전 상태로 돌아가게 했다.
  • 한계: 이번 검증은 특정 시점의 코드·설정·테스트에 대한 것이며, 전문 침투 테스트나 장기간 운영을 대신하지 않는다.

1. 연결만 되면 끝이라고 생각했다

개발 중인 앱이나 웹 서버를 다른 사람에게 보여주려면 외부에서 접근할 주소가 필요하다. 가장 단순한 방법은 공유기에서 포트를 열고 NAS나 개발 장비로 전달하는 것이지만, 서비스가 늘어나면 공개 포트와 방화벽 규칙을 계속 관리해야 한다. 개발 장비가 사설 네트워크 안에 있으면 환경에 따라 포트 포워딩을 구성하기 어려울 수도 있다.

내가 원한 사용법은 단순했다.

아래 명령은 게시용 예시다. 실제 토큰, 운영 호스트와 내부 경로는 포함하지 않았다.

ktunnel login
ktunnel http 3000

그러면 로컬의 127.0.0.1:3000이 외부 HTTPS 주소로 연결되는 방식이다. 실제 터널 데이터의 전달은 이미 검증된 frp가 맡고, ktunnel은 사용자 인증과 주소 정책, 운영 화면과 업데이트를 담당하도록 나누기로 했다.

처음에는 이 정도면 충분해 보였다. 하지만 “내가 혼자 쓰는 스크립트”와 “다른 사람이 로그인해서 사용하는 서비스” 사이에는 생각보다 큰 차이가 있었다.

2. 처음에는 250줄짜리 Bash로 시작했다

최초 버전은 frp 설정 파일을 임시로 만들고 실행이 끝나면 지우는 Bash 래퍼였다. 사용자가 포트와 이름을 넘기면 형용사·명사·4자리 hex를 조합해 주소를 만들고, 로컬에 frpc가 없으면 Docker 이미지로 실행하는 방식이었다.

frp를 직접 다시 구현하지 않고 설정을 감싸는 쪽을 택한 것은 합리적인 출발이었다. 터널 연결 자체는 이미 해결된 문제였고, 내가 만들고 싶은 것은 그 위의 사용 경험과 운영 경계였기 때문이다.

그러나 곧 한계가 보였다. 모든 PC에 frpc나 Docker가 필요했고, 여러 사람이 하나의 서버 주소와 공유 키를 알아야 했다. 한 명의 접근만 막고 싶어도 모두의 키를 바꿔야 했다. 로컬 스크립트가 편리해질수록, 그 스크립트가 의존하는 환경도 같이 늘어났다.

그래서 Go 정적 바이너리로 다시 만들었다. 사용자가 Go나 Python, Docker를 먼저 설치하지 않아도 되게 하고, 터널을 여는 클라이언트와 사용자·정책을 관리하는 서버를 분리했다.

3. 공유 비밀키를 사용자별 토큰으로 바꿨다

여러 사람이 쓰는 순간 가장 먼저 바꿔야 했던 것은 인증 방식이었다. 공유 키 하나로 모두를 구분하는 구조에서는 누가 사용했는지 추적하기 어렵고, 특정 사용자만 차단하기도 어렵다.

ktunnel은 관리자와 사용자가 같은 포털에 로그인하되 역할에 따라 화면과 권한을 나눈다. 회원가입은 열지 않고 관리자가 계정을 발급한다. 사용자는 첫 로그인 뒤 비밀번호를 바꾸고, 자신의 터널 토큰을 직접 발급하거나 폐기한다.

관리자 포털에서 발급한 사용자별 토큰이 ktunnel과 frps 플러그인, ktunneld 정책 검사, SQLite를 거치는 흐름
공유 키 대신 사용자별 토큰을 발급하고 ktunneld에서 정책을 검사하는 흐름
  • 토큰은 발급할 때 한 번만 원문을 보여준다.
  • 운영 DB에는 원문 토큰 대신 SHA-256 해시를 저장한다.
  • 토큰마다 사용자, 라벨과 상태를 구분한다.
  • 사용자 계정을 비활성화하거나 개별 토큰만 폐기할 수 있다.
  • 예약 서브도메인과 동시 터널 수를 사용자 단위로 제한한다.

토큰이 없어졌을 때 DB에서 원문을 다시 꺼낼 수 없다는 점은 불편하다. 하지만 그 불편함이 토큰을 비밀번호처럼 취급하게 만들었다. 인증 정보를 “나중에 다시 보여줄 수 있는 설정값”으로 두지 않고, 잃어버리면 새로 발급해야 하는 자격으로 바꾼 것이다.

# 게시용 예시
ktunnel login
ktunnel http 3000 --name demo
ktunnel status

클라이언트의 명령은 짧지만 서버에서는 로그인, 프록시 생성, 하트비트, 연결 종료마다 정책을 확인한다. 토큰이 폐기되면 다음 하트비트에 반영되고, 강제 종료된 토큰은 잠시 재연결을 거부해 즉시 다시 살아나는 상황을 막는다.

4. frp를 쓰되 제어 계층은 직접 만들었다

현재 구조는 네 조각으로 나뉜다.

외부 HTTPS 요청이 Synology NAS의 nginx와 frps를 거쳐 ktunnel 클라이언트와 로컬 포트로 전달되는 구조
외부 HTTPS 요청이 nginx와 frps를 거쳐 로컬 서비스로 전달되는 ktunnel의 전체 흐름
구성 요소역할
ktunnel노트북이나 개발 장비에서 토큰을 사용해 HTTP·TCP 터널을 연다.
frps클라이언트와 외부 요청 사이의 터널 데이터 전달을 맡는다.
ktunneld토큰, 사용자, 예약 주소, 동시성 한도, 포털과 감사 기록을 관리한다.
nginx공개 HTTPS를 종료하고 포털과 터널 호스트를 분리한다.

외부 사용자가 name.example.com에 접속하면 nginx가 HTTPS를 종료하고 Host 헤더를 기준으로 frps에 전달한다. frps는 이미 연결된 클라이언트 채널을 역방향으로 사용하기 때문에 로컬 PC에 인바운드 포트를 열 필요가 없다.

인증과 터널 승인은 frps의 서버 플러그인 훅을 통해 ktunneld에 물어본다. 로그인, 새 프록시 생성, 하트비트와 연결 종료를 각각 확인하므로 “로그인할 때만 인증했다”로 끝나지 않는다. 실제 운영에서는 요청이 들어오는 순간에도 토큰 상태와 사용자 정책을 다시 확인해야 했다.

이렇게 나누니 frp의 연결 안정성을 그대로 이용하면서도, 사용자별 권한과 운영 기록은 프로젝트의 요구에 맞게 설계할 수 있었다. frp의 TLS 설정은 공식 TLS 문서를 기준으로 확인했고, 플러그인 훅은 frp 서버 플러그인 문서와 구현을 대조했다.

5. 암호화된 연결과 서버 검증은 달랐다

처음부터 TLS를 사용했다고 해서 클라이언트가 올바른 서버에 연결했다는 뜻은 아니었다. 암호화된 통로가 만들어져도, 그 인증서가 어느 호스트의 것인지 클라이언트가 확인하지 않으면 다른 서버를 신뢰할 수 있다.

기존 kt1 토큰에는 relay 주소와 포트, 도메인은 있었지만 클라이언트가 확인해야 할 TLS 서버 이름이 없었다. v0.6의 kt2 토큰에는 TLS 이름을 함께 넣었다. 클라이언트는 내장한 Let’s Encrypt ISRG Root X1/X2를 기준으로 인증서 체인과 호스트 이름을 확인한다.

이 과정에서 호환성을 일부러 대칭으로 만들지 않았다. 서버는 기존 kt1을 잠시 이해할 수 있지만, 새 클라이언트는 kt1으로 새 터널을 열지 않는다. 사용자가 안전한 kt2를 다시 발급하도록 강제하는 선택이다.

  • 인증서 체인이 신뢰할 수 있는 루트까지 이어지는가?
  • 인증서의 SAN에 실제 연결 호스트 이름이 있는가?
  • 예상하지 못한 호스트나 자체 서명 인증서를 거부하는가?
  • 검증이 실패했을 때 터널을 열지 않는가?

내가 “TLS를 적용했다”고 쓰는 것과 “클라이언트가 서버 신원을 확인한다”고 쓰는 것은 다른 문장이었다. 후자의 문장을 쓰려면 정상 인증서뿐 아니라 오호스트, 비신뢰 체인과 실패 시 동작까지 확인해야 했다.

6. 공개 저장소가 되자 공급망까지 운영 대상이었다

개발 중에는 바이너리를 내 장비에 복사하면 됐다. 공개 릴리스가 되자 설치 파일 자체를 사용자가 믿을 수 있어야 했다. 그래서 v0.6.0 릴리스에서는 OS와 아키텍처별 정적 바이너리와 플랫폼 wheel을 만들고, SHA256SUMS를 함께 공개했다.

frps를 사용하는 부분도 같은 기준으로 확인했다. 공식 frp v0.71.0만 내려받고 upstream과 저장소에 고정한 SHA-256을 이중으로 비교해, 확인되지 않은 파일을 그대로 실행하지 않도록 했다. 업데이트는 새 파일을 바로 덮어쓰지 않고 임시 파일에 받은 뒤 검증이 끝나면 원자적으로 교체한다. 실패하면 이전 파일과 실행 상태로 돌아간다.

ktunnel update --check
ktunnel update
# 같은 기능
ktunnel upgrade

공개 설치 경로도 직접 실행해 봤다. 파일로 실행할 때는 괜찮았던 BASH_SOURCE[0]curl | bash에서는 비어 있을 수 있었다. 문서에 적은 설치 명령이 실제 사용자 명령이라면, 그 실행 방식 자체가 테스트 케이스가 되어야 했다.

현재 공개 저장소와 v0.6.0 릴리스는 GitHub 저장소v0.6.0 Release에서 확인할 수 있다.

7. Synology NAS에 올리면서 운영 모델이 바뀌었다

NAS에 서버를 올리는 일 자체는 컨테이너 하나를 실행하는 것으로 끝나지 않았다. 포털, frps, ktunneld, nginx가 서로 어떤 주소에서 듣고 어떤 파일을 읽는지 정해야 했다. 포털과 frps 플러그인처럼 외부에 노출할 필요가 없는 포트는 loopback에 묶었고, 외부 사용자는 HTTPS 주소로만 들어오게 했다.

운영 구성의 중요한 부분은 저장소였다. SQLite는 WAL 모드로 사용하고, 데이터 디렉터리와 파일 권한을 좁혔다. 컨테이너는 비root 사용자, read-only root filesystem, capability 제거와 no-new-privileges를 적용했다. 작은 서비스라도 NAS의 다른 서비스와 같은 장비에서 움직이기 때문에 권한 경계를 먼저 정해야 했다.

Synology에서 Docker 서비스를 HTTPS로 공개하는 전체 흐름은 이미 Synology NAS에서 Docker 서비스를 HTTPS로 공개하는 전체 과정에 정리해 두었다. ktunnel에서는 이 흐름에 와일드카드 터널 도메인과 포털의 정확한 호스트를 추가했다.

와일드카드 인증서에는 DNS-01이 필요했다

*.example.com 형태의 주소를 쓰려면 와일드카드 인증서가 필요하다. 와일드카드는 HTTP-01이 아니라 DNS-01 검증을 사용한다. 그래서 ACME 클라이언트가 _acme-challenge 아래 TXT 레코드를 쓸 수 있도록 DNS 권한을 별도로 준비했다.

  • 한 이름에 CNAME과 기존 TXT를 함께 두지 않는다.
  • *.example.coma.b.example.com까지 포함하지 않는다.
  • 처음에는 Let’s Encrypt staging으로 발급 절차를 확인한다.
  • 실제 운영 토큰과 DNS 식별자는 문서와 게시물에 남기지 않는다.

DNS-01의 동작 원리는 Let’s Encrypt 공식 문서를 기준으로 확인했다.

인증서가 바뀌면 relay도 다시 읽어야 했다

인증서 자동 갱신이 성공했다고 해서 실행 중인 frps가 새 인증서를 사용한다고 단정할 수는 없다. 프로세스가 시작할 때 인증서를 메모리에 읽었다면, 파일만 바뀐 뒤에는 이전 인증서를 계속 사용할 수 있다.

DNS-01 인증서 갱신 후 체인과 SAN을 검사하고 frps를 재생성한 뒤 실패하면 이전 인증서로 롤백하는 흐름
인증서 갱신 뒤 실제 TLS 응답을 확인하고 실패하면 이전 상태로 돌아가는 운영 흐름

그래서 DSM 작업 스케줄러에서 갱신 여부를 확인하고, 인증서와 키의 짝, relay 호스트 SAN, 신뢰 체인과 남은 유효기간을 먼저 검사하도록 했다. 검사에 통과한 경우에만 파일을 원자적으로 교체하고 frps를 다시 만들었다. 인증된 TLS health check가 실패하면 이전 PEM과 컨테이너 상태로 돌아간다.

NAS 작업 스케줄러가 WordPress의 백그라운드 작업을 실행하는 과정을 확인한 기록은 WordPress 예약 발행이 멈췄다에서도 이어진다.

처음에는 인증서 갱신 도우미가 Compose로 frps를 재생성할 것이라고 가정했지만, 기존 운영 컨테이너는 docker run으로 만들어져 이름이 충돌했다. 운영 리소스의 소유 모델과 자동화가 기대하는 소유 모델이 달랐던 것이다. 기존 컨테이너를 롤백 지점으로 남기고 Compose 관리 컨테이너로 전환한 뒤에야 자동화가 의도대로 동작했다.

8. 실패가 가장 많은 것을 알려줬다

개발 타임라인을 보면 기능이 차례로 늘어난 것처럼 보인다. 실제로는 실패가 다음 설계의 범위를 정해 줬다.

문제처음의 판단바꾼 결정
nginx를 바꿨는데 포털이 404원본 설정 파일을 수정하면 적용됐을 것실제로 nginx가 읽는 등록 파일을 확인하고 nginx -t 뒤 reload
CI만 테스트 실패제품 코드 문제일 것CI의 네트워크·환경값과 실제 실행 환경의 의미를 분리
curl | bash 설치 오류파일 실행이 되니 설치도 될 것파이프 stdin 실행을 별도의 회귀 테스트로 추가
인증서 갱신 후 frps 충돌Compose가 기존 컨테이너도 관리할 것운영 컨테이너의 소유 모델을 먼저 맞추고 롤백 지점 보존
공유 키로 사용자 구분 불가소규모면 괜찮을 것사용자별 토큰·폐기·한도·감사 로그로 전환

이 중에서 가장 오래 남은 교훈은 “설정 파일이 맞다”와 “실행 중인 서비스가 그 파일을 읽었다”가 다르다는 점이다. NAS는 여러 서비스가 한 장비의 파일과 프록시를 공유한다. 그러니 자동화도 파일을 바꾸는 데서 끝나지 않고, 실제 프로세스의 health와 외부 응답까지 확인해야 했다.

9. v0.6.0에서 확인한 범위

v0.6.0 릴리스에는 ktunnelktunneld의 macOS·Linux·Windows 바이너리, 플랫폼 wheel과 SHA256SUMS가 포함됐다. 저장소에서는 Go 테스트, race detector, vet, 설치 스크립트, frp checksum, 인증서 갱신과 롤백 관련 검증을 함께 실행했다.

NAS에서 실제로 확인한 범위는 다음과 같다.

  • 포털 로그인과 HTTPS 응답
  • 사용자별 토큰 발급·폐기와 권한 분리
  • 정상·오호스트·비신뢰 인증서에 대한 relay TLS 동작
  • nginx와 frps, ktunneld 사이의 요청 흐름
  • 인증서 갱신 뒤 frps 재생성과 TLS health check
  • 공개 릴리스 파일의 checksum과 설치 경로
  • Docker 컨테이너의 비root·read-only·capability 설정

그렇다고 보안이 완성됐다고 쓰지는 않는다. Docker의 PID limit처럼 NAS 커널에서 지원되지 않아 적용하지 못한 항목도 있었고, OpenSSH의 post-quantum key exchange 관련 경고도 남았다. 이 SSH 경고는 ktunnel 웹과 relay TLS 검증과는 별개다.

또 하나의 릴리스 정리 과제도 확인했다. v0.6.0 태그는 CI 수정 커밋을 가리키고, curl | bash 설치 수정은 그 뒤의 main에 반영됐다. 현재 main installer는 v0.6.0 릴리스 자산을 받지만, 두 지점을 다음 패치 릴리스에서 다시 맞추는 편이 깔끔하다.

내가 글에서 사용할 수 있는 정확한 표현은 “확인한 범위에서 알려진 우회 경로를 찾지 못했다”에 가깝다. 특정 시점의 코드 리뷰와 자동 테스트, 블랙박스 검증이 전문 침투 테스트나 장기간 운영을 대신하지 않기 때문이다.

10. 결국 만든 것은 터널이 아니라 경계였다

처음에는 로컬 포트를 외부에 보여주는 명령 하나를 만들고 싶었다. 실제로 그 명령을 만드는 일은 오래 걸리지 않았다. 시간이 많이 든 곳은 그 다음이었다.

누가 터널을 열 수 있는지, 어느 주소를 사용할 수 있는지, 인증서가 정말 내가 기대한 서버를 가리키는지, 업데이트 파일이 바뀌지 않았는지, 인증서 갱신에 실패했을 때 어디로 돌아갈지를 정해야 했다.

그래서 ktunnel은 단순히 frp 설정을 감추는 도구에서 멈추지 않았다. 사용자별 토큰과 포털, relay TLS 검증, 릴리스 checksum, 자동 업데이트와 인증서 롤백을 갖춘 작은 운영 시스템이 됐다.

NAS에 서비스를 올리는 일은 “컨테이너가 실행 중이다”에서 끝나지 않는다. 다시 만들 수 있어야 하고, 데이터와 인증서를 어디에 보관하는지 알아야 하며, 실패했을 때 이전 상태로 돌아갈 수 있어야 한다. 연결 성공보다 운영 가능한 상태를 만드는 일이 더 오래 걸렸던 이유다.

공개 저장소는 GitHub의 ktunnel 저장소에서 확인할 수 있다. Synology NAS에서 Docker 서비스를 HTTPS로 공개하는 기본 경로는 기존 NAS HTTPS 기록, NAS의 장애 범위와 캐시 과열을 점검한 경험은 NVMe 캐시 과열 기록에서 이어진다.

참고 자료

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다