초록 배경에 픽셀아트 검은 고양이와 터미널 창이 놓인 tailcat 배너. 터미널에는 tailcat을 실행하자 tc로 시작하는 서버 주소가 출력된 화면이 떠 있다

이미지 출처: tailscale.com/tailcat

기기가 늘어나면서 원격 접속이 애매해졌다. 사무실에는 Windows PC 한 대, Mac mini M1 한 대, M4 Mac mini 두 대가 있다. 밖에서 쓰는 건 iPad Air M3와 MacBook Air M4. 다 합치면 여섯 대다. 밖에서 자유롭게 붙으려면 이 여섯 대가 전부 같은 망에 있어야 한다.

Tailscale은 훌륭하다. 그런데 무료 플랜은 세 대까지다. 여섯 대를 넣으려면 유료로 가야 하고, 개인 요금제가 월 8달러다. 문제는 내가 밖에서 사무실 기계에 붙는 빈도가 그렇게 높지 않다는 것이다. 한 달에 몇 번 쓰자고 매달 8달러를 내는 건 아깝고, 그렇다고 세 대만 골라 넣자니 늘 빠진 기계가 필요해지는 머피의 법칙에 걸린다.

그러던 차에 tailcat을 봤다.

tailcat이 뭔가#

Tailscale이 직접 만든 도구인데, 한 줄 소개가 이렇다.

like netcat, but over Tailscale’s data plane, without Tailscale’s control plane.

Tailscale의 핵심은 크게 두 덩어리다. 하나는 데이터 플레인 — WireGuard 암호화, NAT 통과, DERP 릴레이 같은 실제로 패킷을 나르는 부분. 다른 하나는 컨트롤 플레인 — 계정, tailnet, ACL, 기기 목록을 관리하는 중앙 서버. 과금과 기기 수 제한이 걸리는 곳은 후자다.

tailcat은 앞의 것만 떼어 쓴다. 그래서:

  • 계정이 필요 없다. 로그인도 tailnet도 없다.
  • 데몬이 필요 없다. tailscaled 같은 상주 서비스가 없다.
  • root가 필요 없다. 유저스페이스 WireGuard라서 TUN 장치도, 라우팅 테이블 수정도, DNS 변경도 하지 않는다.
  • 무료다. 기기 수 제한이 없다. 셀 주체가 없으니까.

대신 컨트롤 플레인이 하던 일을 사람이 해야 한다. 어떤 기계가 어떤 기계에 붙을 수 있는지를 중앙에서 관리해 주지 않는다. 이 글의 절반은 그 얘기다.

어떻게 동작하나#

서버 쪽에서 tailcat을 띄우면 연결 토큰이 하나 나온다. tc로 시작하는 문자열이고, 내부적으로는 ConnBlob이라고 부른다.

tcXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

이 토큰 안에 뭐가 들었는지는 tailcat parse로 볼 수 있다.

$ tailcat parse tcXXXX...
{
    "ServerPublic": "nodekey:2052c8e1...",
    "ServerDiscoPublic": "discokey:1db98e96...",
    "RegionID": 304
}

세 가지다. 서버의 WireGuard 공개키, 경로 탐색용 공개키, 그리고 DERP 리전 번호. 리전 304는 도쿄다. 짧은 형태는 95바이트쯤 된다.

WireGuard 공개키가 뭔가 (모르면 열어보기)

WireGuard는 VPN 프로토콜이다. 두 기계 사이에 암호화된 터널을 뚫는 방식인데, 기존 VPN보다 훨씬 단순하고 빨라서 요즘 사실상 표준이 됐다. Tailscale도 내부적으로 이걸 쓴다.

WireGuard는 아이디와 비밀번호를 쓰지 않는다. 대신 공개키 암호를 쓴다. 핵심은 이렇다.

터널에 참여하는 기계는 각자 키 한 쌍을 만든다. 두 개가 세트로 나오고, 수학적으로 짝이 맞물려 있다.

성격어디에 있나
개인키 (private key)절대 비밀그 기계 밖으로 절대 안 나간다
공개키 (public key)공개해도 됨상대에게 알려준다

비유하자면 공개키는 자물쇠, 개인키는 열쇠다. 자물쇠는 아무한테나 나눠줘도 된다. 그걸로는 잠글 수만 있고 열지는 못하니까. 열 수 있는 건 짝이 맞는 열쇠 하나뿐이다.

그래서 공개키는 신원처럼 쓰인다. “나는 이 공개키의 주인이다”를 증명하려면 짝이 되는 개인키가 있어야 하는데, 그건 복사할 수 없는 곳에 있다. 공개키만 훔쳐봐야 아무 소용이 없다.

tailcat 토큰에 서버의 공개키가 들어 있는 이유가 이것이다. 클라이언트는 그 공개키로 “내가 지금 붙은 상대가 진짜 그 서버가 맞는지” 를 확인한다. 중간에 누가 끼어들어 서버인 척해도, 개인키가 없으면 그 검증을 통과하지 못한다.

--allow는 이 관계를 반대 방향으로 쓴 것이다. 서버가 클라이언트의 공개키를 미리 알아두고, 그 공개키의 주인임을 증명하는 쪽만 들여보낸다. 그래서 공개키 목록이 유출돼도 위험하지 않다. 자물쇠 목록이 새어나간 것뿐이고, 열쇠는 각자 기계 안에 있다.

글에 나오는 nodekey: 는 그냥 “이건 노드의 공개키다”라는 접두어이고, 뒤의 긴 16진수가 키 본체(32바이트)다. discokey: 는 경로 탐색용으로 따로 쓰는 공개키인데, 직결 경로를 찾는 통신에서 노출되어도 진짜 WireGuard 공개키가 드러나지 않도록 분리해 둔 것이다.

DERP 리전이 뭔가 (모르면 열어보기)

먼저 풀어야 할 문제가 있다. 공유기 뒤에 있는 기계는 밖에서 직접 부를 수 없다.

사무실 맥의 주소는 192.168.1.210 같은 사설 주소다. 이건 그 건물 안에서만 의미가 있고 인터넷에는 존재하지 않는다. 밖에 있는 MacBook이 이 주소로 연결을 걸 방법이 없다. 이걸 NAT 문제라고 한다. 포트 포워딩이나 공인 IP가 필요했던 이유가 이것이다.

그래서 양쪽이 각자 밖으로 나가서 만나는 방식을 쓴다. 나가는 연결은 공유기가 막지 않으니까.

DERP(Designated Encrypted Relay for Packets)가 그 만남의 장소다. Tailscale이 전 세계에 운영하는 중계 서버이고, tailcat은 이걸 그대로 빌려 쓴다. 서버와 클라이언트가 각자 같은 DERP에 접속하면 서로를 찾을 수 있다.

DERP가 하는 일은 두 가지다.

  1. 랑데부 — 서로의 존재와 접속 정보를 교환하는 중개. 처음 연결이 여기서 성립한다.
  2. 폴백 — 직결이 실패하면 계속 중계해 준다. 느리지만 연결은 유지된다.

그리고 연결이 붙은 뒤에는 양쪽이 서로의 공인 주소를 교환해 직접 연결(P2P) 을 시도한다. 성공하면 DERP를 버리고 직결로 갈아탄다. 이 글 본문에 나오는 pong in 69.21ms via DERP(tok) 는 아직 중계 경유라는 뜻이고, via 203.0.113.7:41641 처럼 IP가 찍히면 직결로 승격된 것이다.

여기서 중요한 점. DERP는 내용을 볼 수 없다. 암호화는 양 끝의 WireGuard가 담당하고 DERP는 암호문 덩어리를 그대로 넘길 뿐이다. 중계 서버를 신뢰하지 않아도 되는 구조다.

리전은 이 중계 서버들을 지역 단위로 묶은 것이고, 번호로 식별한다. 304번이 도쿄다. 한국에서 쓰기에 지연이 가장 낮아서 이게 선택됐다.

리전을 고정해야 하는 이유가 이제 분명해진다. 양쪽이 서로 다른 리전에 접속하면 만나지 못한다. 기본값인 --region=auto 는 “띄울 때마다 가까운 곳을 다시 골라라”인데, 그러면 토큰에 적힌 리전과 서버가 실제로 붙은 리전이 어긋날 수 있다. DNS에 올려두고 오래 쓸 주소라면 --fixed-region 으로 한 번 정해서 박아두어야 한다.

연결은 이렇게 흐른다.

  1. 서버가 DERP 릴레이에 붙어서 대기한다.
  2. 클라이언트가 토큰을 파싱해 서버의 공개키와 리전을 알아내고, 같은 릴레이에 붙는다.
  3. 클라이언트가 릴레이를 통해 “Meow” 핑을 보낸다. 서버가 이걸 받아 클라이언트를 WireGuard 피어 목록에 추가하고 “Meowed”로 답한다.
  4. 양쪽이 피어로 등록됐으니 표준 WireGuard 핸드셰이크가 진행된다. 터널 완성.
  5. 동시에 양쪽이 STUN으로 알아낸 공인 IP:포트를 서로에게 알리고 UDP 홀펀칭을 시도한다. 성공하면 릴레이를 버리고 P2P 직결로 승격한다. 실패해도 릴레이로 계속 동작한다.

TCP/IP 스택은 gVisor 기반 netstack을 프로세스 안에서 돌린다. OS 네트워크 설정을 건드리지 않고도 연결을 받고 걸 수 있는 이유가 이것이다.

가장 중요한 것 — 토큰은 비밀번호가 아니다#

여기서 한참 고민했고, 결국 이 글에서 제일 하고 싶은 얘기다.

토큰에는 공개키만 들어 있다. 개인키는 없다. 아무나 tailcat parse로 내용을 열어볼 수 있다. 그런데도 이 토큰은 사실상 접근 권한처럼 동작한다. 공식 문서가 이걸 이렇게 표현한다.

the WireGuard public key that acts as the unlisted connection capability

비공개 링크인증이 아니다. 기본 상태에서 서버를 띄우면 “누구세요”를 묻는 단계 자체가 없다. 토큰을 가진 사람은 전부 들어온다.

기본값인 임시 키는 이 위험을 시간으로 막는다. 프로세스가 죽으면 그 주소는 영구히 사라진다. 그래서 토큰이 유출돼도 피해가 그 실행 한 번으로 끝난다.

문제는 주소를 고정하는 순간이다. 문서가 직접 경고한다.

The flip side: anyone you’ve ever shared that address with can connect to any future server using that key, unless you restrict clients with tailcat serve --allow.

과거에 한 번이라도 그 토큰을 본 사람은, 앞으로 그 키로 띄우는 모든 서버에 들어온다. 여기에 DNS 게시까지 얹으면 dig 한 번이면 누구나 읽을 수 있으니, 비공개 링크의 “비공개”가 완전히 사라진다.

그래서 --allow#

고정 주소나 DNS를 쓸 거면 --allow는 선택이 아니라 필수다. 클라이언트 쪽에서 신원 키를 만들고, 그 공개키를 서버가 화이트리스트에 넣는다.

# 클라이언트 (예: MacBook Air)
$ tailcat genkey --client --key=client-default
# wrote file to ~/Library/Application Support/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7...

# 서버
$ tailcat serve --allow=nodekey:cfb6bfa77a0654d7... 22

이렇게 하면 토큰이 공개 DNS에 박혀 있어도 안전하다. 이유가 셋이다.

  • 걸러지는 지점이 WireGuard 핸드셰이크 단계다. 허용되지 않은 클라이언트의 패킷은 뒤쪽 서비스에 도달조차 못 한다.
  • 허용 목록의 신원을 사칭하려면 클라이언트 개인키가 있어야 한다. 공개키만 알아서는 못 붙는다.
  • 거절이 아니라 무응답이다. 문서 표현으로 “they can’t reach the SSH server, or even learn that one is running”. 서버가 거기 있다는 사실조차 드러나지 않는다.

즉 보안 경계가 “토큰을 아느냐”에서 “클라이언트 개인키를 가졌느냐”로 옮겨간다. 이게 정상적인 형태다.

정리하면 이렇다.

구성접근 가능한 사람
임시 키 (기본값)그 실행 중에 토큰을 받은 사람. 프로세스 종료 시 주소 소멸
고정 키, --allow 없음그 토큰을 한 번이라도 본 사람 전원, 영구히
고정 키 + DNS 게시, --allow 없음인터넷 전체
고정 키 + DNS 게시 + --allow개인키를 가진 허용 클라이언트만

클라이언트 키는 기기마다 따로#

키 파일 하나를 여러 기기에 복사해도 동작은 한다. 파일 안에 호스트명이나 머신 ID 같은 기기 종속 정보가 없기 때문이다. 그래도 하지 않는 게 좋다.

첫째, 동시 접속이 깨진다. 서버는 클라이언트를 노드 공개키로 색인한다. 같은 키를 쓰는 두 기기는 공개키가 같으니 그 색인에서 한 칸을 두고 싸운다. 게다가 각 피어의 주소가 공개키에서 결정적으로 유도되므로 두 기기가 같은 주소를 갖는다. 노트북과 데스크톱에서 동시에 붙으면 세션이 서로를 밀어낸다.

둘째, 개별 취소가 불가능해진다. --allow의 의미가 “이 기기를 허용”이 아니라 “이 키를 가진 아무나 허용”이 된다. 기기 하나를 분실했을 때 그것만 끊을 방법이 없어서 전부 재발급해야 한다.

--allow는 원래 쉼표 구분 목록을 받는다. 공유할 이유가 없다.

$ tailcat serve --allow=nodekey:aaaa...,nodekey:bbbb...,nodekey:cccc... all

키 이름은 전부 client-default로 통일해도 된다. 그게 클라이언트 모드에서 자동으로 로드되는 매직 이름이라, 각 기기에서 아무 플래그 없이 그냥 붙으면 된다.

설치#

Arch (Omarchy)#

AUR에 두 개가 올라와 있다.

yay -S tailcat-bin   # 공식 릴리스 바이너리 (권장)
yay -S tailcat       # 소스 빌드

tailcat-bin을 골랐다. PKGBUILD가 깨끗하다. 공식 GitHub 릴리스 tar.gz를 sha256 검증하고 /usr/bin/tailcat과 라이선스, README만 설치한다. 서비스 유닛도 후킹도 없다. 의존성이 glibc 하나뿐이라 빌드 시간이 0이고, pacman이 관리하니 yay -Syu에 딸려 업데이트된다.

go install도 가능하지만 권하지 않는다. README가 go build -tags "$(cat build-tags.txt)"를 권하는데 go install은 그 태그를 먹지 않아서 기능이 빠질 수 있다.

macOS#

Homebrew로 끝난다.

brew install tailcat

주의할 게 하나 있다. 릴리스 페이지에는 리눅스와 윈도우 바이너리만 있고 macOS 바이너리가 없다. 맥에서는 Homebrew(또는 Go 빌드)가 사실상 유일한 경로다.

그리고 비대화형 SSH로 원격 조작할 때 함정이 있다. SSH의 PATH가 /usr/bin:/bin:/usr/sbin:/sbin뿐이라 /opt/homebrew/bin이 안 잡힌다. 스크립트나 launchd에서는 항상 절대경로로 부른다.

/opt/homebrew/bin/tailcat version

주소를 고정하고 DNS에 올리기#

여기부터가 실제로 쓸 만해지는 지점이다.

고정 키 만들기#

$ tailcat genkey --key=default --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXX...

--fixed-region이 중요하다. 기본값인 --region=auto는 “시작할 때마다 가까운 리전을 고른다”는 뜻인데, DNS에 게시할 주소라면 리전이 고정돼야 한다. 클라이언트와 재시작한 서버가 같은 곳에서 만나야 하기 때문이다. --fixed-region은 genkey 시점에 한 번 측정해서 그 리전 번호를 토큰과 키 파일 양쪽에 박아 넣는다.

제대로 박혔는지는 tailcat parse로 확인한다. RegionID가 실제 번호(내 경우 304, 도쿄)면 성공이고, -1이면 auto가 그대로 남은 것이다.

키 파일은 0600, 디렉터리는 0700으로 저장된다. 경로는 Go의 os.UserConfigDir() 기준이라 리눅스는 ~/.config/tailcat/keys/, 맥은 ~/Library/Application Support/tailcat/keys/다.

한 가지 더. default매직 이름이다. 한 번 만들어두면 그냥 tailcat serve 8080만 쳐도 임시 키가 아니라 저장된 고정 키를 조용히 쓴다. 시작 로그에 어느 쪽인지 찍히니 그걸 봐야 한다.

# 🐈 Server listening with saved key "default": tcXXXX...   ← 고정 키
# 🐈 Server listening with new address: tcXXXX...           ← 임시 키

DNS TXT에 게시#

토큰을 TXT 레코드에 넣으면 CLI가 토큰을 받는 모든 자리에서 도메인 이름을 쓸 수 있다.

ow.wangsy.com.  300  IN  TXT  "tailcat=tcXXXXXXXX..."

Cloudflare API로 넣었다.

curl -s -X POST \
  -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -d '{"type":"TXT","name":"ow","content":"tailcat=tcXXXX...","ttl":300}'

이러면 이렇게 쓴다.

$ tailcat ssh ow.wangsy.com
$ tailcat ping ow.wangsy.com
$ tailcat ow.wangsy.com 8080

토큰 95바이트를 외우거나 복사해 다니는 대신 도메인 이름 하나면 된다. 이게 생각보다 훨씬 쾌적하다. 결국 네 대를 전부 물렸다.

호스트주소
Omarchy 리눅스 데스크톱ow.wangsy.com
Mac mini M1macmini-m1.wangsy.com
M4 Mac mini #1m4-01.wangsy.com
M4 Mac mini #2m4-02.wangsy.com

키를 재발급하면 TXT 레코드도 반드시 같이 갱신해야 한다. 안 그러면 게시된 주소가 죽은 주소를 가리킨다.

부팅할 때 자동으로 띄우기#

리눅스 — systemd 유저 서비스#

~/.config/systemd/user/tailcat-serve.service:

[Unit]
Description=tailcat server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/tailcat serve \
  --key=default \
  --allow=nodekey:XXXXXXXX \
  all
Restart=always
RestartSec=5
NoNewPrivileges=true

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now tailcat-serve.service
loginctl enable-linger $USER    # 로그인 없이 부팅 시 기동

--key=default를 굳이 명시한 게 포인트다. 매직 이름이라 생략해도 같은 키를 쓰지만, 명시해 두면 키 파일이 사라졌을 때 임시 키로 조용히 넘어가지 않고 서비스가 실패한다. DNS에 게시된 주소와 실제 주소가 어긋난 채로 도는 상태가 제일 나쁘기 때문에, 시끄럽게 죽는 쪽이 낫다.

enable-linger가 없으면 로그인해야 뜬다. 데스크톱이라도 이건 켜두는 게 맞다.

macOS — launchd, 그리고 FileVault 함정#

맥은 두 갈래다. ~/Library/LaunchAgentsLaunchAgent는 사용자 로그인 시 뜨고, /Library/LaunchDaemonsLaunchDaemon은 부팅 시 뜬다. 서버로 쓸 맥이면 후자가 맞다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.wangsy.tailcat-serve</string>
  <key>UserName</key>
  <string>사용자명</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/tailcat</string>
    <string>serve</string>
    <string>--key=/Users/사용자명/Library/Application Support/tailcat/keys/default.private.json</string>
    <string>--allow=nodekey:XXXXXXXX</string>
    <string>all</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardOutPath</key>
  <string>/Users/사용자명/Library/Logs/tailcat-serve.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/사용자명/Library/Logs/tailcat-serve.log</string>
</dict>
</plist>
sudo chown root:wheel /Library/LaunchDaemons/com.wangsy.tailcat-serve.plist
sudo chmod 644 /Library/LaunchDaemons/com.wangsy.tailcat-serve.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.wangsy.tailcat-serve.plist

--key를 절대경로로 준 이유가 있다. 데몬 컨텍스트에서는 HOME이 기대대로 잡힌다는 보장이 없어서 os.UserConfigDir() 해석이 어긋날 수 있다. 경로에 슬래시가 있으면 tailcat이 이름 대신 경로로 해석하니, 그냥 절대경로로 박는 게 안전하다.

그런데 세 대 중 한 대에서 막혔다. FileVault다.

M4 Mac mini 두 대는 FileVault가 꺼져 있고 sudo도 NOPASSWD라 LaunchDaemon이 깔끔하게 들어갔다. 진짜 무인 부팅 기동이 된다. 반면 Mac mini M1은 FileVault가 켜져 있고 자동 로그인도 꺼져 있었다.

FileVault가 켜져 있으면 재부팅 후 디스크가 잠긴 채로 선다. 사람이 잠금을 풀기 전에는 LaunchDaemon도 LaunchAgent도 아무것도 실행되지 않는다. 암호화된 볼륨 위에 있으니 당연하다. 즉 이 머신에서는 “재부팅하면 항상 뜬다”가 원리적으로 불가능하다. 잠금을 푸는 순간 로그인이 되고 그때 LaunchAgent가 뜨는 게 최선이다.

거기에 이 머신은 sudo에 암호가 걸려 있어서 /Library/LaunchDaemons에 쓸 수도 없었다. 그래서 LaunchAgent로 갔는데, 위 사정 때문에 실질적인 손해가 없다. 어차피 Daemon도 잠금 해제 전에는 안 뜬다.

무인 재부팅 복귀가 꼭 필요하면 FileVault를 끄는 수밖에 없다. 디스크 암호화를 포기하는 거라 그냥 제약을 안고 가기로 했다.

덤 — 모든 포트가 열린다#

여기가 예상 밖으로 좋았던 부분이다.

tailcat serve는 포트 번호, 포트 범위, 서비스 이름을 인자로 받는다.

tailcat serve 22,80,443,8000-8999   # 특정 포트들
tailcat serve all                   # 모든 TCP 포트
tailcat serve exit-node             # 이 머신을 거쳐 네트워크 전체
tailcat serve no-auth-ssh           # 인증 없는 SSH 서버
tailcat serve files                 # SFTP 파일 서버

all로 열었다. 그러면 그 머신의 모든 TCP 포트가 localhost로 프록시된다. 화면 원격 제어만이 아니라,

  • 임시로 띄운 개발 서버 (localhost:5173, localhost:8000)
  • 데이터베이스 포트
  • 원래 127.0.0.1에만 바인딩돼서 LAN에서도 안 보이던 것들

전부 밖에서 닿는다. 포트 포워딩 설정도, 공유기 건드릴 일도, 공인 IP도 필요 없다. 이게 진짜 편하다.

다만 바로 이 점을 인식해야 한다. all127.0.0.1 전용 포트까지 노출한다. 지금까지 네트워크에서 닿을 수 없다는 전제로 아무렇게나 띄워둔 것들이 전부 열린다는 뜻이다. --allow로 막혀 있으니 실질 위험은 낮지만, 노출 범위가 늘어난 건 사실이다. 불안하면 all 대신 22,5173,8000 같이 필요한 것만 나열하면 된다.

exit-node는 일부러 뺐다. 그건 “이 머신을 거쳐 LAN과 인터넷 전체”라서 범위가 훨씬 넓다. 사무실의 다른 기기까지 밖에서 닿게 하고 싶다면 그때 켜면 된다.

RustDesk를 밖에서 붙이기#

맥 세 대에 RustDesk가 깔려 있다. 원래는 LAN에서만 직결로 썼다.

RustDesk는 기본적으로 공용 rendezvous 서버를 통해 밖에서도 붙는다. 그런데 내 설정의 중계 서버가 rs-ny.rustdesk.com, 뉴욕이었다. 한국에서 한국으로 붙는데 뉴욕을 돌 수 있다는 얘기다. 게다가 연결 수립을 남의 공용 인프라에 의존한다.

tailcat을 깔았으니 이걸 터널에 태우는 게 낫다. RustDesk의 Direct IP Access를 켜면 21118 포트에서 직접 받는데, serve all에 이미 포함돼 있다.

MacBook의 ~/.ssh/config에 이걸 넣고,

Host macmini-m1
  HostName macmini-m1.wangsy.com
  ProxyCommand tailcat %h %p

포트를 당겨온 다음,

ssh -L 21118:localhost:21118 macmini-m1

RustDesk에서 ID 대신 Direct IP Access로 127.0.0.1:21118에 붙는다. 인증은 기존 고정 비밀번호 그대로다.

이러면 트래픽이 WireGuard 터널 안으로 들어가고, 경로도 도쿄 DERP거나 P2P 직결이라 뉴욕을 안 돈다. 무엇보다 클라이언트 키가 없으면 21118에 닿지도 못한다. 접근 통제가 한 겹 더 생긴 셈이다.

삽질 기록#

tailcat 주소 8473은 포트 포워딩이 아니다#

이걸 착각했다. 클라이언트 모드에 포트를 주면 로컬에 리스너가 생길 줄 알았는데 아니다. netcat과 똑같은 stdin/stdout 파이프다. 서버의 그 포트로 TCP 연결 하나를 열어서 내 터미널의 입출력에 붙인다. 브라우저로 localhost:8473을 열 수는 없다.

# 이건 파이프. 한 번 찔러보는 용도로는 이걸로 충분하다
printf 'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n' | tailcat ow.wangsy.com 8473

서브커맨드 목록에 forward 같은 건 없다. 진짜 로컬 포트 포워딩이 필요하면 두 가지다.

# 1) SOCKS5 프록시 — 로컬 리스너가 생긴다
tailcat socks --listen=127.0.0.1:1080 ow.wangsy.com

# 2) SSH를 얹는다 (ProxyCommand 설정 후)
ssh -L 8473:localhost:8473 ow

ProxyCommand tailcat %h %p가 동작하는 이유가 바로 그 파이프 동작이다. ProxyCommand가 요구하는 게 정확히 “stdin/stdout에 붙은 TCP 연결”이라서 그대로 들어맞는다. tailcat ssh도 내부적으로 같은 일을 한다.

RustDesk 비밀번호가 계속 틀렸다#

터널을 다 만들고 붙었는데 비밀번호가 계속 거부됐다. 서버 로그를 보니 이랬다.

INFO  direct access from [::1]:64942
DEBUG #290 Connection opened from [::1]:64942.
INFO  #290 Connection closed: Peer close

[::1]은 localhost다. 즉 터널은 완벽하게 동작하고 있었다. SSH 포워딩을 타고 tailcat을 거쳐 RustDesk까지 연결이 정상 도달했고, 비밀번호 단계에서만 막힌 것이다.

원인은 설정이었다. verification-method = 'use-permanent-password'. 이 모드에서는 고정 비밀번호만 받고 RustDesk 창에 표시되는 일회용 비밀번호는 아예 거부된다. 화면에 뜬 임시 비밀번호를 쓰고 있었으니 계속 틀릴 수밖에.

RustDesk는 고정 비밀번호를 암호화해서 저장하고 앱에서 평문을 보여주지 않는다. 그래서 이건 반드시 별도로 기록해 둬야 한다. 나는 1Password에 호스트별로 항목을 만들어 뒀고, 다행히 거기 있던 값이 맞았다. 이번에 각 항목에 tailcat 주소와 직결 포트 필드를 추가해서, 항목 하나만 열면 외부 접속에 필요한 게 다 나오게 정리했다.

참고로 CLI로 비밀번호를 재설정하려면 관리자 권한이 필요하다.

sudo /Applications/RustDesk.app/Contents/MacOS/RustDesk --password '새비밀번호'

권한 없이 실행하면 Installation and administrative privileges required!가 뜬다.

차단이 되는지 반드시 대조군으로 확인할 것#

--allow를 걸어놓고 허용되지 않은 기계에서 붙어보면 타임아웃이 난다. 그런데 이게 정말 allow 목록이 막은 건지, 그냥 네트워크가 안 되는 건지 구분이 안 된다. 무응답이 정상 동작이라 에러 메시지가 없기 때문이다.

그래서 대조군을 하나 띄웠다.

# allow 목록 없는 임시 서버
$ tailcat serve --key=new 9999 &
$ tailcat ping <>
pong in 69.21ms via DERP(tok)

이게 69ms에 돌아왔으니 DERP 경로는 멀쩡하다. 따라서 아까 타임아웃은 순전히 --allow가 막은 것으로 확정된다. 보안 설정은 “막혔다”가 아니라 “막혀야 할 것만 막혔다”까지 확인해야 의미가 있다.

한계#

iPad에서는 안 된다. tailcat은 CLI 도구고 iOS/iPadOS 앱이 없다. WebAssembly 데모가 있긴 한데 DERP 릴레이 전용에 실험 단계다. 그래서 iPad는 Tailscale로 계속 간다. 무료 3대 안에 iPad와 자주 쓰는 기계 몇 개를 넣고, 나머지는 tailcat으로 커버하는 조합이 됐다. 결과적으로 둘을 병행하는 게 답이었다.

안정성 보장이 없다. 문서에 명시돼 있다. Go API도, CLI 플래그도, 와이어 포맷도 바뀔 수 있다. 공용 DERP 릴레이는 SLA도 처리량 목표도 없고, 언제든 어떤 이유로든 접근이 차단될 수 있다. 전부 best effort다. 상시 운영 서비스의 접근 경로로 삼을 물건은 아니다.

컨트롤 플레인이 없다는 건 관리도 없다는 뜻이다. 기기를 추가하려면 각 서버의 --allow 목록을 손으로 고치고 서비스를 재시작해야 한다. 네 대면 네 군데를 고친다. Tailscale이 ACL 한 곳에서 해주던 일이다. 기기가 더 늘면 이게 부담이 될 것 같다.

Windows는 절반만 된다. CLI는 있지만 SSH 서버 기능이 없다. 내 Windows PC는 아직 안 붙였다.

정리#

여섯 대를 다 넣자니 월 8달러, 세 대만 넣자니 늘 모자란 상황이었다. tailcat이 그 사이를 메웠다.

지금 상태는 이렇다. 리눅스 데스크톱 한 대와 맥 세 대가 각각 고정 주소를 갖고 wangsy.com 서브도메인에 물려 있다. MacBook Air 하나만 클라이언트 키로 허용돼 있고, 그 키는 1Password에 있다. 밖에서 도메인 이름 하나로 SSH도, RustDesk 화면도, 임시로 띄운 웹서버도 다 붙는다. iPad는 Tailscale로 남겨뒀다.

가장 중요한 교훈 하나만 남기자면 이거다. tailcat 토큰은 비밀번호가 아니다. 주소를 고정하고 DNS에 올리는 순간 그 사실이 치명적이 되고, --allow만이 유일한 방어선이다. 임시로 한 번 쓰고 버릴 거면 기본값 그대로가 제일 안전하다. 상시로 띄울 거면 --allow를 먼저 걸고 나서 나머지를 하는 게 순서다.