TAILSCALE, 네트워킹
Tailscale의 exit node는 켜는 순간 기기의 공용 인터넷 경로를 통째로 가져간다. 정작 필요한 건 브라우저 하나만 클라우드를 거치고 나머지는 그대로 두는 것이었다. 답은 유저스페이스 SOCKS5 프록시 하나였다.
환경. Omarchy(Arch Linux + Hyprland/Wayland)에서 작업했다. 프록시 자체(유저스페이스 tailscaled + SOCKS5)는 OS를 가리지 않고, systemd 사용자 서비스, .desktop 런처, 창 규칙 같은 부분만 리눅스에 맞춰져 있다.
가끔 특정 사이트를 “다른 나라에서 접속한 것처럼” 열어야 할 때가 있다. 지역이 갈리는 서비스를 확인하거나, 집이 아닌 곳에서 나가는 것처럼 보이게 하고 싶을 때다. 마침 tailnet 안에 인터넷 출구 역할을 해 주는 클라우드 노드가 하나 있었다. AWS Lightsail 인스턴스인데, 월정액이 저렴한 소형 VPS다. 여기에 Tailscale을 깔고 tailscale up --advertise-exit-node로 exit node를 광고해 두면, tailnet의 다른 기기가 그 노드를 골라 인터넷 출구로 쓸 수 있다.
Lightsail은 여러 리전에서 인스턴스를 만들 수 있어, “어느 나라에서 나가는 것처럼 보일지”를 리전 선택으로 정할 수 있다. Lightsail 지원 리전 중 기본 활성만 해도 미국(버지니아, 오하이오, 오리건), 도쿄, 서울, 싱가포르, 시드니, 뭄바이, 프랑크푸르트, 아일랜드, 런던, 파리, 스톡홀름, 캐나다 중부, 상파울루가 있고, 홍콩, 자카르타, 말레이시아, 스페인은 옵트인으로 켜서 쓴다. 이 글은 미국 리전을 예로 든다.
문제는 “정도”였다. exit node를 켜면 노트북의 모든 트래픽이 미국을 거친다. 업무용 통신, 로컬 개발 서버, 집 안 기기 접근까지 전부 한 다리를 더 건넌다. 필요한 건 그게 아니었다. 브라우저 창 하나만 미국으로 보내고, 시스템의 나머지는 평소처럼 집 회선을 그대로 쓰는 것. 말하자면 “앱 단위 exit node”다.
먼저 Tailscale이 exit node를 어떻게 거는지 들여다봤다. exit node를 켜면 클라이언트는 정책 라우팅 규칙 하나를 심는다. 리눅스에서 라우팅 규칙을 뽑아 보면 from all, 즉 “출발지 불문 모든 트래픽”을 Tailscale 전용 테이블로 몰아넣는 규칙이 보인다. 그 테이블의 기본 경로가 exit node를 향한다. 설계가 이렇다 보니 “목적지 A만 통과, 나머지는 직접” 같은 갈래를 낼 여지가 없다.
이 단순한 구성에서 눈에 띄는 예외는 둘이다. 같은 tailnet 안 기기끼리의 통신은 원래 exit node를 거치지 않고, --exit-node-allow-lan-access로 열어 둔 로컬망도 직접 닿는다. 그 밖의 인터넷 트래픽은 exit node로 간다. 더 복잡한 tailnet이라면 subnet router나 app connector로 향하는 경로도 별도로 고려해야 한다.
“그럼 라우팅 규칙을 손으로 고쳐 일부만 우회시키면?” 하고 잠깐 생각했지만 접었다. 그 규칙들은 Tailscale이 스스로 계속 조정하는 상태다. 사람이 끼어들면 다음 갱신에서 덮이거나 충돌한다. 선택적 우회는 Tailscale의 라우팅 안에서가 아니라 밖에서 풀어야 했다.
돌파구는 tailscaled --help 안에 있었다. Tailscale 데몬에는 --tun=userspace-networking 모드가 있고, 같이 --socks5-server와 --outbound-http-proxy-listen 옵션이 딸려 온다.
이 모드의 핵심은 이름 그대로다. 커널 TUN 장치(tailscale0)를 만들지 않는다. 시스템 라우팅 테이블에 손을 대지 않는다는 뜻이다. 대신 tailnet으로 오가는 연결을 로컬 SOCKS5(그리고 HTTP) 프록시로 노출한다. 여기서 방법이 보였다. 이 유저스페이스 인스턴스에 exit node를 걸면, 그 프록시를 통과하는 트래픽만 exit node로 나간다. 커널 라우팅은 그대로니 시스템의 나머지는 영향이 없다.
확신이 서기 전에 먼저 시험했다. 두 번째 tailscaled를 유저스페이스 모드로 잠깐 띄워 보니, root 없이도 떴다. TUN을 안 만드니 특권이 필요 없다. 로그에 tun "userspace-networking"과 SOCKS5 리스닝이 찍히는 걸 확인하고 나서야 본 구축에 들어갔다.
exit node가 트래픽을 통째로 가져가는 건 커널 라우팅을 잡기 때문이다. 커널 라우팅을 안 건드리는 유저스페이스 인스턴스를 하나 더 두면, 같은 기기에서 “통째로”와 “이것만”이 공존한다.
이제 구조는 단순하다. 본체 Tailscale은 평소처럼 커널 모드로 두고, 프록시 전용 tailscaled를 하나 더 사용자 서비스로 돌린다. 상태 파일, 소켓, 포트를 본체와 분리하는 게 핵심이다. 이 인스턴스는 tailnet에 별도의 노드로 등록된다.
그림 1. 한 기기, 두 경로. 위쪽(주황)은 프록시를 가리킨 앱만 거치는 우회 경로, 아래쪽(회색)은 그 외 모든 앱의 평소 경로다. 두 경로는 서로 간섭하지 않는다.
사용자 서비스로 등록하면 이런 모양이다. 유저스페이스 모드라 root가 필요 없어 systemd --user로 돌아간다.
# ~/.config/systemd/user/ts-exitproxy.service
[Unit]
Description=Tailscale exit-node SOCKS5/HTTP proxy (userspace)
[Service]
ExecStart=/usr/bin/tailscaled \
--tun=userspace-networking \
--socks5-server=127.0.0.1:1055 \
--outbound-http-proxy-listen=127.0.0.1:1056 \
--statedir=%h/.local/share/ts-exitproxy \
--socket=%t/ts-exitproxy.sock \
--port=0
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
[Install] 섹션이 있어야 systemctl --user enable --now ts-exitproxy.service로 부팅 자동 기동이 걸린다(enable이 WantedBy를 보고 심링크를 만든다). 처음 한 번만 이 새 노드를 tailnet에 로그인시키고 exit node를 지정하면 된다. 제어할 때는 본체가 아니라 이 프록시의 소켓을 가리켜야 하므로, 얇은 래퍼를 하나 만들어 두면 편하다.
# 프록시 인스턴스 전용 제어 래퍼 (tsproxy)
tailscale --socket="$XDG_RUNTIME_DIR/ts-exitproxy.sock" "$@"
# 최초 1회: 로그인 + exit node 지정
tsproxy up --exit-node=<EXIT_NODE_TS_IP_OR_NAME> --hostname=my-exitproxy \
--accept-dns=false --shields-up
여기서 exit node는 AWS 공인 IP가 아니라 tailnet 안의 Tailscale IP(또는 노드 이름)다. --shields-up은 이 노드로 들어오는 연결을 막는다. 순수하게 나가는 프록시이므로 인바운드는 필요 없다. 데몬만 계속 돌리고 tailscale up을 다시 부르지 않는 게 중요하다. 이유는 뒤에서 설명한다.
구축이 됐다고 믿지 말고, 같은 시각에 두 경로로 각각 공인 IP를 물어보면 끝난다. 직접 경로와 프록시 경로가 서로 다른 IP로 나오면 성공이다.
# 직접(시스템 기본 경로)
curl -s https://ifconfig.me
# 프록시 경유
curl -s --socks5-hostname 127.0.0.1:1055 https://ifconfig.me
표 1. 경로별로 나가는 공인 IP. 프록시를 쓴 요청만 클라우드 리전으로 나갔다.
| 경로 | 나가는 공인 IP | 위치 |
|---|---|---|
| 시스템 직접 | 홈 회선 IP | 사용자 지역 |
| SOCKS5 프록시 (:1055) | 클라우드 IP | AWS 미국 리전 |
| HTTP 프록시 (:1056) | 같은 클라우드 IP | AWS 미국 리전 |
이름 해석도 새지 않는다. --socks5-hostname(원격 DNS)으로 요청하면 도메인 이름 자체를 프록시에 넘겨 exit node 쪽에서 해석한다. 로컬에서 DNS 조회가 일어나지 않으니, 위치가 이름 조회로 드러나는 누수도 없다.
공개 전 검증은 공인 IP 하나로 끝내지 말고 몇 가지를 함께 본다.
ERR_PROXY_CONNECTION_FAILED로 실패하는가.선택적 우회에서 가장 중요한 질문은 “프록시가 죽으면?”이다. 최악은 조용히 집 IP로 새는 것이다. 다행히 이 구조는 그렇지 않다. 전용 브라우저는 프록시만 바라보게 설정돼 있어, 프록시가 응답하지 않으면 외부 웹 요청이 ERR_PROXY_CONNECTION_FAILED로 실패한다. 직접 연결로 폴백하지 않는다. 실제로 죽은 포트로 SOCKS5 요청을 보내면 데이터 없이 연결 오류로 끝난다. 다만 범위를 정확히 해 두자. 여기서 검증한 것은 일반 HTTP/HTTPS 요청이다. Chromium은 localhost, 127.0.0.1, link-local 주소는 프록시를 거치지 않고, WebRTC 같은 UDP 기능은 SOCKS5 프록시를 타지 않는다. WebRTC로 실제 IP가 새는 것까지 막으려면 브라우저 실행에 --force-webrtc-ip-handling-policy=disable_non_proxied_udp를 더하고 따로 확인한다.
아래 그림에서 시나리오를 바꿔 보자. 트래픽이 어디까지 흐르고, 브라우저가 결국 어떤 IP로 나가는지(혹은 막히는지)를 보여 준다.
그림 2. 시나리오를 눌러 경로 변화를 확인한다. 자동으로 일어나는 고장(프록시 다운, exit node 오프라인, 키 만료)은 전부 “막힘”이고, 실제 IP가 새는 경우는 마지막 하나뿐이다. JavaScript가 꺼져 있으면 아래 표로 같은 내용을 볼 수 있다.
표 2. 장애 상황별 결과.
| 상황 | 브라우저 | 실제 IP 노출 |
|---|---|---|
| 프록시 프로세스 다운 | 먹통 | 없음 |
| exit node 오프라인 (설정 유지) | 먹통 | 없음 |
| exit node 키 만료 (설정 유지) | 먹통 | 없음 |
| 프록시 노드 키 만료 (재인증 필요) | 먹통 | 없음 |
| exit node 설정이 “해제”됨 (프록시는 생존) | 동작 | 노출 |
표 2의 마지막 줄이 유일한 누수다. 프록시는 살아서 직접 회선이 되는데 exit node 설정만 비워진 상태. 이때는 프록시가 로컬 회선으로 나가므로 집 IP가 드러난다. 그래서 이 상태가 자동으로 생길 수 있는지가 중요했다.
확인해 보니, 자동으로 벌어지는 유일한 이벤트는 노드 키 만료다. 그런데 키 만료는 설정을 “지우는” 게 아니라 상대를 “오프라인”으로 만든다. exit node 설정값은 그대로 남고, Tailscale은 설정된 exit node에 닿지 못하면 트래픽을 막는다. 즉 시간이 지나 저절로 IP가 새는 일은 없다. 누수가 되려면 누군가 설정을 명시적으로 지우거나, 프록시 소켓에 exit node 플래그 없이 tailscale up을 다시 돌려야 한다. 후자가 앞에서 “데몬만 돌리고 up을 다시 부르지 말라”고 한 이유다. tailscale up을 다시 호출할 때는 의도한 설정 플래그를 모두 함께 넘겨야 한다. 빠뜨린 플래그는 유지되지 않아 exit node를 무심코 날릴 수 있다. 그래서 일부만 바꿀 때는 tailscale set을 쓰고, exit node 해제도 tailscale set --exit-node=처럼 명시적으로만 한다.
누수를 막는 장치에서 정작 무서운 건 조용한 고장이다. 어느 순간 프록시가 죽었는데 그걸 모른 채 계속 브라우징하다 실제 IP가 새는 것. 이 구조에서는 그런 일이 없다. 앞 장에서 봤듯 유일한 누수는 exit node 설정을 손수 비우는 경우뿐이고, 크래시로는 그 상태가 만들어지지 않는다.
이유는 설계에 있다. 전용 브라우저가 인터넷에 닿는 길은 SOCKS5 프록시 하나뿐이고, 직접 나가는 경로는 애초에 설정돼 있지 않다. 프록시가 유일한 초크포인트라, 그 지점이 사라지면 브라우저는 갈 곳이 없어 멈춘다. 다른 길로 새는 게 아니라 그냥 선다.
“안 뜨거나 죽는” 상황을 하나씩 봐도 결과는 같다.
Restart=on-failure로 곧 되살아나고, 그 사이 요청은 막힌다.공통점은 하나다. 잘못된 방향(집 IP)으로 새지 않고, 닫힌 채로 멈춘다. 뚫린 채 계속 도는 게 아니라 문이 닫힌다.
이건 공짜가 아니라 설계 덕이다. 브라우저에 “프록시가 안 되면 직접 나가라”는 폴백을 뒀거나, 기기 전체에 exit node를 걸어 두고 그 설정이 어떤 이유로 풀렸다면 크래시 한 번에 샐 수 있다. 직접 폴백을 아예 두지 않고 프록시 하나로 몬 것이 크래시 안전의 핵심이다. 이 보장 밖은 둘뿐이다. exit node 설정을 능동적으로 비우는 경우와, UDP를 직접 쓰는 WebRTC 같은 기능인데, 둘 다 앞에서 다뤘다.
여러 창을 띄우다 보면 “어느 게 우회 창이더라” 하고 헷갈린다. 그래서 브라우저 자체를 눈에 띄게 만들었다. 크로미움을 별도 프로필로 프록시와 함께 띄우되, 테마 색을 주황으로 강제한다.
chromium \
--user-data-dir="$HOME/.local/share/exit-browser" \
--proxy-server="socks5://127.0.0.1:1055" \
--install-autogenerated-theme="255,153,0" \
--force-webrtc-ip-handling-policy="disable_non_proxied_udp" \
--class="exit-browser"
--install-autogenerated-theme는 R,G,B 값 하나로 툴바와 프레임 색을 통째로 바꾼다. 결과는 이렇다. 일반 크로미움 창과 한눈에 갈린다.
덧붙여 socks5:// 스킴을 쓰면 크로미움이 이름 해석까지 프록시에 맡기므로(원격 DNS) DNS 누수도 없다. 색만으로 끝내지 않고, 런처에서 바로 뜨는 앱으로도 등록했다.
데스크톱 환경의 앱 목록은 .desktop 파일로 정의된다. 여기에 실행 명령, 이름, 아이콘, 그리고 실행 창과 이 항목을 잇는 창 클래스를 적는다.
# ~/.local/share/applications/exit-browser.desktop
[Desktop Entry]
Name=Exit Browser (exit node)
Exec=/home/user/.local/bin/exit-browser %U
Icon=/home/user/.local/share/applications/icons/exit-browser.png
Type=Application
StartupWMClass=exit-browser
Categories=Network;WebBrowser;
StartupWMClass는 실행 창(위 --class와 같은 값)을 이 앱 항목에 묶어, 작업 표시줄과 런처가 같은 아이콘으로 인식하게 한다(Wayland나 데스크톱 환경에 따라 앱 ID 매칭 방식이 달라 연결이 다르게 보일 수 있다). 아이콘은 브라우저 테마 색과 맞춘 주황 지구본으로 만들었다.
등록이 끝나면 앱 런처(예: Walker)에서 이름 일부만 쳐도 뜬다. 아래는 런처에 exit를 입력했을 때의 모습을 나타낸 개념도다. 전용 아이콘 덕분에 결과 목록에서 바로 눈에 들어온다.
그림 5. 앱 런처 검색 개념도. 이름 일부를 치면 전용 아이콘과 함께 결과에 뜨고, 눌러서 바로 실행한다.
같은 패턴은 GUI 없는 서버에서도 그대로 쓴다. 브라우저 대신 특정 다운로드 스크립트만 클라우드 IP로 내보내고 싶을 때다. 프록시 서비스를 서버에 올린 뒤, 스크립트가 그 프록시를 쓰게 하면 된다.
가장 손이 덜 가는 방법은 환경변수다. HTTP 프록시(:1056)는 추가 패키지 없이 대다수 HTTP 클라이언트가 존중한다. 내부, 로컬망, tailnet은 NO_PROXY로 빼 둔다. 다만 클라이언트마다 환경변수 대소문자 처리와 NO_PROXY의 CIDR 지원이 달라서(가령 curl은 소문자 http_proxy를 보고, CIDR NO_PROXY는 비교적 최근 버전부터 지원한다), 쓰는 런타임에서 반드시 검증한다.
export HTTPS_PROXY=http://127.0.0.1:1056
export HTTP_PROXY=http://127.0.0.1:1056
export NO_PROXY=localhost,127.0.0.1,192.168.0.0/16,100.64.0.0/10
# 검증: 클라우드 IP가 나오면 성공
HTTPS_PROXY=http://127.0.0.1:1056 curl -s https://ifconfig.me
원격 DNS 표기는 도구마다 다르니 주의한다. curl은 socks5h://(또는 --socks5-hostname)라야 프록시 쪽 DNS를 쓰고 socks5://는 로컬 DNS다. 반면 Chromium은 socks5://만으로도 프록시 쪽 DNS를 쓴다. 스크립트에서 무심코 ALL_PROXY=socks5://…를 쓰면 DNS가 로컬로 샐 수 있다.
헤드리스에서 한 가지 함정: systemd --user 서비스는 로그인 세션이 있어야 뜬다. 로그인 없이 부팅 때부터 상시 기동하려면 그 사용자에게 linger를 켜야 한다(loginctl enable-linger). 이걸 빼먹으면 재부팅 후 프록시가 안 떠서 스크립트가 조용히 실패한다.
오래 두고 쓰려면 몇 가지가 저절로 굴러가야 한다.
표 3. 무중단 운영을 위한 동작.
| 항목 | 동작 |
|---|---|
| 부팅 자동 기동 | systemd --user + linger |
| 크래시 복구 | Restart=on-failure(수 초 후 재시작) |
| exit node 설정 유지 | 상태 파일에 저장, 재시작, 재부팅 후에도 유지 |
키 만료만 챙기면 된다. 프록시 노드와 exit node 모두 일반 노드처럼 키 만료 기한이 있다. 만료되면 앞서 봤듯 “막힘”이 되므로 IP 누수 위험은 아니지만, 어느 날 갑자기 브라우저가 안 열리는 건 성가시다. 오래 무중단으로 쓸 생각이면 관리 콘솔에서 두 노드의 키 만료를 꺼 두는 편이 낫다.
같은 목적에 더 간단한 길도 있다. 원격 호스트에 SSH로 붙을 수 있다면 ssh -D 하나로 그 호스트를 SOCKS5 프록시처럼 쓸 수 있다. 새 tailnet 노드를 만들 필요도 없다. 다만 그건 대상 호스트에 SSH 계정이 열려 있어야 한다.
이번 사례에서는 exit node에 SSH 계정이 없었고, 이미 exit 기능이 설정된 노드를 그대로 재활용하고 싶었다. 그래서 SSH에 기대지 않고 Tailscale의 암호화 경로와 exit 기능을 다시 쓰는 유저스페이스 SOCKS5 방식을 택했다. 정리하면 선택 기준은 이렇다. 대상에 SSH가 되면 ssh -D가 가볍고, exit node를 재활용하거나 SSH가 막혀 있으면 이 패턴이 맞다.
핵심 성질을 다시 말하면, 위 설정과 검증 범위(단일 프록시, 직접 폴백 없음, HTTP/HTTPS 요청 기준)에서 이 구조는 평소엔 자동으로 뜨고, 고장 나면 그 앱의 외부 웹 요청이 안전하게 막힌다. WebRTC 같은 UDP 경로는 앞서 말한 대로 따로 처리한다. “특정 앱만, 안전하게”라는 요구에 잘 맞는다.
# 1) 프록시 데몬 (systemd --user 서비스로 등록)
tailscaled --tun=userspace-networking \
--socks5-server=127.0.0.1:1055 \
--outbound-http-proxy-listen=127.0.0.1:1056 \
--statedir=~/.local/share/ts-exitproxy \
--socket=$XDG_RUNTIME_DIR/ts-exitproxy.sock --port=0
# 2) 최초 1회 로그인 + exit node
tsproxy up --exit-node=<Tailscale-IP|노드명> --hostname=my-exitproxy --accept-dns=false --shields-up
# 3) 검증
curl -s --socks5-hostname 127.0.0.1:1055 https://ifconfig.me
# 4) 전용 브라우저
chromium --user-data-dir=~/.local/share/exit-browser \
--proxy-server=socks5://127.0.0.1:1055 \
--install-autogenerated-theme="255,153,0" \
--force-webrtc-ip-handling-policy="disable_non_proxied_udp" --class=exit-browser
# 5) 헤드리스: 서비스 상시 기동
loginctl enable-linger "$USER"