머신 전체 없이 앱 하나만 VPN으로 라우팅하는 방법
프로세스별 VPN 스위치를 제공하는 비관리 데스크톱은 없습니다. 트래픽을 고르는 계층을 선택하십시오: 바인딩된 소켓, UID 규칙, 방화벽 프로필, 또는 SOCKS 홉.
VPN 클라이언트는 연결되고 브라우저는 동작하지만, 정말로 중요한 그 명령 하나는 여전히 집 인터넷 회선으로 나간다. 아니면 터널이 기본 경로를 차지하면서 로컬 파일 서버가 데스크톱에서 사라지는 반대의 일이 벌어진다. 나는 내부 아티팩트 미러에 접근해야 했던 빌드 에이전트에서 이 문제를 겪었고, 호스트의 나머지는 사무실 LAN에 그대로 있었으며, 클라이언트가 제시한 선택지는 모든 트래픽 아니면 전무, 딱 두 가지였다. 양방향의 요구는 같지만 머신 전체가 아니라 이 프로그램만이라는 것이고, 대부분의 클라이언트에는 그 체크박스가 없는데, 라우팅 테이블이 프로그램이 무엇인지 한 번도 알지 못했기 때문이다.
라우팅 테이블이 당신의 프로그램을 볼 수 없는 이유
프로세스가 패킷을 보내면 스택은 라우팅 테이블에서 목적지 주소를 조회한다. 실행 파일 이름도 포트도 그 조회에는 들어가지 않는다. 도메인 이름이 실패하는 이유도 같은데, 터널은 트래픽을 주소로 운반하고, 이름이 주소로 풀린 시점에는 라우팅이 커널에 남은 유일한 지렛대다. 그래서 «이 사이트만 VPN»은 결국 프록시를 뜻하고, «이 프로그램만 VPN»은 작은 메커니즘 집합 중 하나를 뜻한다.
| Mechanism | What selects | Covers | On tunnel drop |
|---|---|---|---|
| Socket bind | source address | a client you control | connections fail |
| UID rule (Linux) | owning user | every process of one account | connections fail |
| Firewall profile (Windows) | adapter profile | one program | fails closed |
| Network namespace | namespace membership | everything started inside | connections fail |
| SOCKS hop | app proxy setting | one app or one browser profile | connections fail |
이 중 어느 것도 프로세스 단위 경로가 아니다. 각각은 결정을 프로그램, 사용자, 또는 앱 자체의 설정을 볼 수 있는 계층으로 옮긴다.
단일 목적지는 여전히 경로이며, 프로그램 규칙이 아니다. macOS에서 흔한 방법은 «모든 트래픽을 VPN으로 보내기»의 체크를 풀고 호스트 경로를 추가하는 것으로, sudo route add -host internal-tuner.example -interface ppp0 이고 sudo route delete -host internal-tuner.example -interface ppp0로 다시 제거한다. 먼저 ifconfig에서 인터페이스 이름을 읽는데, ppp0는 예시일 뿐이다. 호스트 경로에서는 와일드카드가 동작하지 않으므로 여러 이름과 통신하는 서비스에는 이름마다 항목 하나가 필요하고, 이 규칙은 머신의 모든 프로세스에 적용되지 당신이 염두에 둔 하나에만 적용되는 것이 아니다.
소켓을 터널 주소에 바인딩한다
클라이언트를 직접 제어한다면 바인딩이 가장 값싼 선택자다. 터널의 로컬 주소에 바인딩된 소켓은 목적지 경로가 그곳에 있는 한 해당 인터페이스로 나가고, 머신의 나머지는 평소 경로를 유지한다. 미묘한 점은 바인딩이 출발지 주소를 고르는 반면 라우팅 테이블은 여전히 출구를 고른다는 것이다. 바인딩만으로는 터널로 가는 경로가 없는 트래픽을 옮기지 못하며, «바인딩했는데도 여전히 반대쪽으로 나간다»는 보고 대부분의 배경이 이 오해다.
import socket
s = socket.socket()
s.bind(("10.8.0.2", 0))
s.connect(("build.internal.example", 443))상위 도구들도 같은 발상을 드러내는데, curl --interface 10.8.0.2, PHP의 CURLOPT_INTERFACE, Python HTTP 클라이언트의 source_address 인자가 그것이다. 주소는 터널에 속해야 하고, 여기서의 오타는 라우팅 불만이 아니라 평범한 연결 오류로 실패한다. 주소 대신 장치 이름에 바인딩하는 것은 Linux의 특권이며 Windows나 macOS로 넘어가지 않으므로, 이식 가능한 스크립트는 주소를 넘긴다.
Linux: 소유자로 맞추고, 프로그램 이름이 아니다
커널은 소켓의 소유 사용자를 맞출 수 있으므로 전용 계정이 선택자가 된다. 터널을 자체 라우팅 테이블에 넣고 그 계정의 트래픽만 그곳으로 보낸다:
ip rule add uidrange 1001-1001 lookup 100
ip route add default dev tun0 table 100이것은 터널이 이미 기본 경로가 아닐 때만 동작한다. 클라이언트를 --route-nopull로 시작하거나, --pull-filter ignore "redirect-gateway"로 OpenVPN 2.4 이후에서 밀려온 리디렉션을 걸러낸 다음 테이블 항목을 직접 추가한다. 그렇지 않으면 기본 경로가 둘 존재하고 우선순위가 낮은 쪽이 이긴다. 의도적으로 그렇게 하는 판본은 기본 경로를 터널 게이트웨이로 돌리거나 연결 해제 스크립트에서 처리하는 것으로, 터널이 끊길 때 트래픽을 인터넷에서 멀어지게 하지만 그것은 킬 스위치이지 선택자가 아니며 누군가 제거할 때까지 남는다.
더 강한 판본은 네트워크 네임스페이스다. 그것을 만들고, 그 안에서 클라이언트를 실행하고, ip netns exec로 앱을 시작한다. 네임스페이스 밖에서는 터널 인터페이스가 보이지 않으므로 앱이 실수로 호스트 경로로 되돌아갈 수 없다. 대가는 현실적인데, 네임스페이스는 물리 네트워크에 닿기 위한 veth 쌍과 자체 리졸버 파일이 필요하며, 마운트 네임스페이스는 호스트의 해석 설정을 공유하지 않기 때문이다.
두 가지 실패 유형이 대부분의 누출을 설명한다. 이름 해석은 여전히 호스트 리졸버에서 일어나므로 TCP 연결이 안으로 들어가도 질의는 터널 밖으로 나간다. 그리고 IPv6에는 v4 규칙으로 덮이지 않으므로 자체 ip -6 rule 규칙이 필요하다.
Windows와 macOS: 방화벽을 좁히거나 프록시를 넘긴다
Windows에도 앱 단위 경로 선택자는 없지만 방화벽 규칙을 프로필로 좁힐 수 있고, VPN 어댑터를 «공용»으로 분류한 채 집 LAN을 «개인»으로 둘 수 있다. «개인»과 «도메인» 프로필에서 그 프로그램의 아웃바운드 차단 규칙을 만들고 «공용»을 열어 두면, 앱은 터널을 통해서만 네트워크에 닿는다. 먼저 연결의 네트워크 범주를 설정하고, 그다음 방화벽 모니터 창에서 규칙이 일치했는지 확인한다. 이것은 차단이지 경로가 아니라서 터널이 끊기면 닫힌 상태로 실패하고, 방화벽을 무시하는 앱은 막지 못한다.
macOS는 다른 도구로 같은 논리를 따른다. SSH로 도달하는 SOCKS 리스너가 가장 침습이 적은 선택지인데, ssh -D 1080 user@host가 로컬 포트 하나를 만들고 그것을 쓰도록 설정된 앱만 그 경로를 타기 때문이다. pf 규칙은 사용자나 그룹으로 프로그램을 차단할 수 있지만 그 프로그램만을 위해 터널을 고르지는 여전히 못한다. Tunnelblick은 도메인으로 거르는 것 자체가 불가능한데 경로는 주소로 동작하기 때문이며, OpenConnect 사용자는 서버가 너무 넓은 프리픽스 목록을 밀어낼 때 vpn-slice에 기댄다.
두 가지 우회로가 플랫폼 도구가 놓치는 부분을 덮는다. 도메인별로 프록시를 바꾸는 브라우저 확장이 «이 사이트만»에 대한 실용적 답인데, 이름이 아직 존재하는 앱 계층에서 동작하며 대상은 브라우저 하나뿐이다. 거친 방법은 공급업체 클라이언트를 가상 머신에서 돌려 게스트가 경로 변경을 소유하고 호스트가 자기 경로를 유지하게 하는 것이며, 유지해야 할 네트워크 스택이 둘이 된다.
바인딩 수단이 없는 도구를 위한 SOCKS 홉
대부분의 앱은 인터페이스에 대해 아무것도 몰라도 HTTP나 SOCKS 프록시로 향하게 할 수 있다. SSH 클라이언트는 서버 쪽 변경 없이 하나를 제공한다:
ssh -N -D 127.0.0.1:9999 [email protected]그 포트를 쓰도록 설정된 모든 것이 배스천에서 나가고, 데스크톱의 나머지는 평소 연결을 유지한다. 터널 주소에 바인딩된 로컬 3proxy 인스턴스는 SSH 없이 포트 하나에 대해 같은 일을 하고, stunnel는 TLS로 감싼 단일 포트를 덮는다. 한계는 현실적인데, 프록시는 사용법을 아는 프로토콜만 운반하고, UDP에는 많은 클라이언트의 «SOCKS5» 설정이 켜지 않는 SOCKS5 UDP 연관이 필요하며, DNS는 프록시가 해석해야 하고(curl에서는 socks5h), 그렇지 않으면 이름이 로컬 리졸버로 새어 나간다.
다른 머신이 필요로 하지 않는 한 리스너는 루프백에 둔다.
경로는 상태 표시가 아니라 캡처로 확인한다
앱의 상태 창은 설정을 설명할 뿐 패킷을 설명하지 않는다. 두 인터페이스에서 캡처해 비교한다:
tcpdump -i any -n port 443
ss -tnp | grep 443연결이 물리 인터페이스에 나타나고 터널 쪽에 대응 항목이 없다면, 클라이언트가 무엇을 보고하든 선택자는 작동하지 않는 것이다. IPv6와 리졸버를 따로 확인하고, 보조 프로세스나 상속된 시스템 프록시 같은 두 번째 경로가 있는지도 확인한다.
그다음 요구에 맞는 경계를 고르는데, 사이트 하나에는 프록시로 충분하고, 계정 하나에는 UID 규칙으로 충분하며, 연결이 닫힌 상태로 실패해야 한다면 방화벽 프로필로 충분하고, 네임스페이스만이 터널을 바깥의 모든 것으로부터 숨기는 유일한 선택지다.