Tech_News

Tailcat 뜯어보기: 계정도 제어 서버도 없이 WireGuard로 방화벽 뒤 두 서버를 잇는 법

TeEm0 2026. 8. 28. 09:00
728x90

1. netcat으로는 안 되는 순간들

온프렘 랙에 있는 서버 하나에서 로그 3GB를 클라우드 쪽 분석 VM으로 넘겨야 하는 상황. 예전 같으면 nc -l 9000 > dump.tar 하고 반대쪽에서 nc 10.0.0.5 9000 < dump.tar 하면 끝났다. 문제는 요즘 어떤 환경에서도 이게 잘 안 된다는 거다.

  • 양쪽 다 NAT 뒤에 있고, 인바운드 포트를 열려면 방화벽 변경 요청서를 써야 한다. 보통 이틀 걸린다.
  • 클라우드 쪽은 Security Group을, 온프렘 쪽은 L4 방화벽 정책을 각각 다른 팀이 관리한다.
  • 어렵게 뚫어도 netcat은 평문이다. 사내망이라도 요즘은 감사에서 걸린다.
  • 임시로 열었던 포트를 닫는 걸 까먹는다. 이게 제일 흔하다.

그래서 다들 SSH 터널, ngrok, 아니면 그냥 Tailscale을 깐다. 그런데 Tailscale은 계정이 필요하고 노드가 tailnet에 등록된다. "일회성 디버깅 하나 하려고 회사 tailnet에 이 서버를 붙여야 하나?" 하는 지점에서 걸린다.

Tailcat은 Tailscale이 직접 만든 도구인데, 자기네 표현이 재밌다. "Tailscale without Tailscale, by Tailscale". Tailscale의 데이터 평면(magicsock)만 떼어내서 netcat처럼 쓰게 만든 물건이다. 계정 없고, 제어 서버(coordination server) 없고, root 권한도 필요 없다. 라우팅 테이블이나 DNS를 건드리지 않는 순수 유저스페이스 CLI/라이브러리다.

2. 데이터 평면만 떼어낸다는 게 무슨 뜻인가

Tailscale은 크게 두 덩어리로 나뉜다.

  • 제어 평면(control plane): 누가 어떤 tailnet에 속하는지, 어떤 노드가 어떤 IP를 갖는지, ACL은 뭔지를 관리. 여기가 계정과 로그인이 붙는 곳이다.
  • 데이터 평면(data plane): 실제 패킷을 나르는 부분. 내부적으로 magicsock이라 부르는 컴포넌트가 WireGuard 암호화 터널을 만들고, DERP 릴레이를 사이드 채널 삼아 NAT 홀펀칭을 시도한다. 홀펀칭이 실패하면 DERP가 최후의 릴레이로 남는다.

Tailcat은 이 중 아래쪽만 쓴다. 그럼 제어 평면이 하던 "상대방 공개키와 위치를 알려주는" 일은 누가 하냐? 사람이 한다. 정확히는 out-of-band로 알아서 전달한다. 슬랙, 메신저, DNS TXT 레코드, 뭐든 상관없다.

연결 토큰의 정체

서버 쪽에서 tailcat을 실행하면 토큰 하나가 나온다.

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

이 문자열이 뭔지는 tailcat parse로 직접 까볼 수 있다.

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
  "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
  "RegionID": 302
}

딱 두 개다. 서버의 WireGuard 공개키어느 DERP 리전에서 만나면 되는지. 제어 평면이 API로 내려주던 정보를 base64 비슷한 한 줄로 압축해서 사람이 복붙하게 만든 것뿐이다. 이걸 보고 나면 구조가 단순해서 오히려 허탈하다.

클라이언트는 이 토큰을 받아 해당 DERP 리전으로 붙고, 거기서 서버와 만나 WireGuard 핸드셰이크를 한다. 핸드셰이크가 끝나면 magicsock이 서로의 실제 IP:포트를 교환하면서 직접 UDP 경로를 뚫으려 시도한다. 성공하면 DERP를 버리고 P2P로 올라탄다. 이게 실제로 되는지는 ping으로 확인할 수 있다.

$ tailcat ping --until-direct tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

첫 번째 pong은 릴레이 경유(42ms), 두 번째는 직접 경로(1.2ms). NAT 트래버설이 성공하는 순간이 로그에 그대로 찍힌다. --until-direct는 직접 경로가 뚫릴 때까지 계속 핑을 날리고, 타임아웃(기본 10초) 안에 못 뚫으면 non-zero로 종료한다. 스크립트에서 "직접 연결 가능한지" 사전 체크용으로 쓸 만하다.

토큰의 두 가지 형태

기본 토큰에는 리전 ID만 들어있어서, 클라이언트가 DERP map(https://tailcat.dev/derpmap.json)을 한 번 받아와야 한다. 이걸 미리 풀어서 릴레이 호스트명·IP까지 박아 넣은 긴 토큰을 만들 수 있다.

$ tailcat resolve tcomFwWCC...FpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

파싱해보면 tc302a.ipn.dev, IPv4/IPv6가 그대로 들어있다. 서버가 처음부터 이 형태로 출력하게 하려면 --full-address를 쓴다. 길어지지만 DERP map 조회 왕복이 없어서 연결이 빨라진다.

3. 실무에서 어떻게 쓰나 — 그리고 어디서 넘어지나

시나리오 A: 방화벽 뒤 웹 서버 임시 노출

온프렘 서버에서 돌던 애플리케이션이 이상하게 동작해서, 클라우드에 있는 내 개발 VM에서 직접 찔러보고 싶다. 인바운드 포트는 못 연다.

# [온프렘 서버]
$ tailcat --serve=8080,8443
# 🐈 Server listening with new address: tcXXXXXXXXX

# [클라우드 VM] 토큰 슬랙으로 받아서
$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....

더 실용적인 건 SOCKS5 모드다. curl이나 다른 CLI 도구를 그대로 터널 위로 태울 수 있다. 토큰 자체를 호스트명처럼 쓸 수 있다는 점이 재밌다.

$ tailcat socks tcXXXXXXXXX curl http://server.tailcat:8081/
# 또는 토큰을 호스트명 자리에 직접
$ tailcat socks curl http://tcXXXXXXXXX:8081/

주의할 점: 토큰은 대소문자를 구분한다. curl 같은 CLI에서는 문제없지만 브라우저는 호스트명을 소문자로 바꿔버려서 안 된다. 원문에 명시된 제약이다.

시나리오 B: 포트 안 열고 SSH 서버 운영

이게 개인적으로 제일 인상 깊었던 예제다. WireGuard가 SSH 데몬보다 먼저 클라이언트를 인증하기 때문에, 허용되지 않은 상대는 SSH 서버가 돌고 있다는 사실조차 알 수 없다.

# [클라이언트] 클라이언트 신원 키 생성 (공개키만 서버에 알려주면 됨)
client$ tailcat genkey --client
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

# [서버] 리전 고정한 서버 키 생성 후, 저 클라이언트만 허용
server$ tailcat genkey --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX
server$ tailcat --serve=22 --allow=nodekey:cfb6bf...ddfd16
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

토큰을 DNS TXT 레코드로 올려두면 이름으로 접속된다.

my-server.example.com.  300  IN  TXT  "tailcat=tcXXXXXXXXX"

client$ tailcat ssh my-server.example.com

클라이언트는 client-default 키가 있으면 알아서 쓰기 때문에 추가 플래그가 없다. 포트 노킹이나 포트 포워딩 없이 이게 된다는 게 꽤 매력적이다.

흔한 함정

(1) default 키의 마법 이름 문제. 이게 제일 사고 나기 쉽다. tailcat genkey를 한 번 해두면 ~/.config/tailcat/keys/default.private.json이 생기고, 그 뒤로는 그냥 tailcat만 쳐도 조용히 그 키를 쓴다. 즉 예전에 어딘가 슬랙에 뿌렸던 토큰이 그대로 살아난다. 시작 로그에서 반드시 구분하자.

# 새 일회용 키 — 프로세스 죽으면 주소도 영구 소멸
# 🐈 Server listening with new address: tcXXXXXXXXX

# 저장된 키 — 과거에 토큰 받은 사람 전부 접속 가능
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

저장 키를 쓸 거면 --allow로 클라이언트를 제한하는 게 사실상 필수다. 일회용이 필요하면 --key=new를 명시하고, 키를 지우려면 tailcat genkey --delete --key=default, 목록은 tailcat genkey --list.

(2) DNS에 올릴 토큰은 리전을 고정해야 한다. 그냥 tailcat genkey--region=auto가 기본이라 "시작할 때 고른다"가 키 파일에 박힌다. 서버가 재시작되면서 다른 리전을 고르면 DNS에 게시한 토큰이 어긋난다. 그래서 위 예제에서 --fixed-region을 쓴 거다. 이건 genkey 시점에 가장 가까운 리전을 한 번 탐색해서 ID를 토큰과 키 파일 양쪽에 박아둔다. 명시적으로 고르려면 --region=이름, 목록은 --region=list.

참고로 README에도 "DERP map이 시간이 지나 바뀌면 클라이언트가 더 견고해져야 한다"는 TODO(이슈 #7)가 남아 있다. 장기 운영용으로 DNS에 박아둘 거면 이 부분은 이슈 트래킹을 해두는 게 좋겠다.

(3) 토큰 하나가 곧 접근 권한이다. --allow를 안 걸면 토큰 아는 사람은 누구나 붙는다. 특히 --serve=no-auth-ssh는 이름 그대로 인증 없는 SSH다. 잠깐 디버깅용으로는 편하지만, 이걸 사내 서버에서 저장 키와 조합해서 띄우는 건 사고다. 인증이 필요하면 --serve=22로 시스템 SSH에 프록시하는 쪽을 쓰자. --serve=exit-node도 마찬가지로, 클라이언트가 서버 쪽 네트워크 전체로 나갈 수 있게 되는 옵션이라 사내망에서는 신중해야 한다.

(4) 직접 연결이 항상 되는 건 아니다. README도 "usually!"라고만 쓴다. 대칭 NAT나 빡빡한 아웃바운드 정책이 걸린 사내망에서는 DERP 릴레이 경유로 남을 수 있다. 이때 기본 릴레이는 Tailscale의 무료 rate-limited 릴레이다. 3GB 로그 덤프 같은 걸 릴레이로 밀면 어떻게 될지는 예상 가능하다. 대용량 전송 전에 tailcat ping --until-direct로 직접 경로부터 확인하고, 상시로 쓸 거면 자체 DERP를 세우자.

# 자체 derper(TLS 인증서 필요, Let's Encrypt 자동 발급 가능) 지정
server$ tailcat genkey --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
server$ tailcat --serve=22

이렇게 하면 토큰 안에 릴레이 호스트명이 박혀서, 클라이언트는 Tailscale의 DERP map 서버나 릴레이에 전혀 접속하지 않는다. rate limit도 내 것만 적용된다. 릴레이가 여러 대면 직접 DERP map JSON을 서빙하고 양쪽에 --derpmap-url로 물려주면 된다. 사내 도입을 검토한다면 사실상 이 구성이 기본이 되어야 한다고 본다.

(5) 아웃바운드가 막혀 있으면 아무것도 안 된다. 이건 도구 문제가 아니라 정책 문제다. DERP 부트스트랩은 HTTPS(443)로 나가고 P2P는 UDP다. 아웃바운드 443조차 프록시 강제인 환경에서는 다음처럼 실패한다.

$ tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tailcat: failed to connect: context deadline exceeded

혹은 토큰을 잘못 복붙했을 때(줄바꿈이 섞이거나 대소문자가 뭉개진 경우) 이런 식의 파싱 실패를 먼저 의심하자.

$ tailcat parse tcomfwwccjs5nkNqAod034nWoJZW0LZ
tailcat: invalid address

정확한 메시지 문구는 버전에 따라 다를 수 있으니 실제 출력 기준으로 확인이 필요하다. 다만 원인은 대체로 아웃바운드 차단 아니면 토큰 복붙 사고 둘 중 하나다. 토큰이 대소문자 구분이라는 점 때문에, 메신저 자동 링크화나 대문자 변환이 끼면 조용히 깨진다.

다른 도구와 언제 갈리나

  • SSH 포트포워딩(ssh -L/-R): 이미 SSH로 닿을 수 있으면 이게 제일 간단하다. 문제는 양쪽 다 NAT 뒤라 SSH 자체가 안 닿을 때. Tailcat은 그 지점을 푼다.
  • ngrok/Cloudflare Tunnel: HTTP 노출과 공개 URL이 목적이면 여전히 이쪽이 낫다. Tailcat 토큰은 브라우저에 못 넣는다.
  • Tailscale 본체: 상시 운영, ACL, 감사 로그, 팀 단위 관리가 필요하면 그냥 Tailscale을 써야 한다. Tailcat에는 중앙 정책 관리가 없다. 신뢰 모델이 "토큰을 아는 사람 + --allow 목록"으로 끝난다.
  • 순정 WireGuard: 성능과 통제는 최고지만 양쪽 공인 IP/포트 개방과 root 권한이 필요하다. Tailcat은 root 없이 유저스페이스에서 돌고 라우팅 테이블을 안 건드린다. 이게 "남의 서버에 잠깐 올려서 쓰기" 관점에서는 큰 차이다.

4. 정리

한 줄 요약: Tailcat은 "계정도 제어 서버도 없이, root 없이, 방화벽 뒤 두 머신을 WireGuard로 직접 잇는 netcat"이다. 제어 평면이 하던 키·위치 교환을 사람이 토큰 복붙으로 대신하는 게 전부고, 그래서 단순하고 그래서 위험 요소도 명확하다.

이럴 때 써라:

  • 온프렘–클라우드 간 일회성 디버깅 터널. 기본값(에페메랄 키)이 정확히 이 용도로 설계되어 있다.
  • 방화벽 정책 변경 리드타임을 못 기다리는 장애 대응 상황.
  • 인바운드 포트를 절대 못 여는 서버에 SSH를 붙여야 할 때(--allow + 자체 DERP 필수).
  • Go 라이브러리(github.com/tailscale/tailcat)로 내부 툴에 P2P 채널을 심을 때. Server{OnTCP: ...} 하나로 시작되는 API가 꽤 얇다.

이럴 때는 쓰지 마라:

  • 상시 운영 인프라의 정식 접근 경로. 중앙 ACL·감사가 없다.
  • 브라우저로 접근해야 하는 서비스 노출.
  • 기본 릴레이 위로 대용량 전송. 자체 DERP를 먼저 세우자.

개인적으로는 "노트북에 넣고 다니는 비상용 도구" 포지션이 딱 맞다고 본다. 저장소가 공개된 지 얼마 안 됐고 README에도 TODO가 남아 있으니, 프로덕션 상시 경로로 넣기 전에는 최신 이슈 목록을 한 번 훑어보길 권한다.

참고 자료

728x90