Android VPN은 IPv6로 동작하는데 브라우저는 여전히 IPv4를 고른다
사설 IPv6 주소가 사라지는 네 단계를 추적합니다. 리졸버, VpnService 선언, 브라우저 후보 검사, 경로 전달입니다.
VPN은 켜져 있고 사설 웹 UI는 노트북에서 IPv6로 응답하는데, Android 휴대폰은 같은 페이지를 여전히 IPv4로 열거나 아예 불러오지 않는다. AAAA 레코드는 존재한다. 터널은 IPv6 주소를 보고한다. 클라이언트에서 잘못된 점은 보이지 않는다. 결과를 결정하는 것은 네 가지 별개의 판단이다. 어느 리졸버가 응답했는지, VpnService가 Android에 무엇을 선언했는지, 브라우저가 어느 주소 패밀리를 시도할 의향이 있었는지, 그리고 패킷이 왕복할 수 있는지. 기본 경로를 추가하고 증상이 옮겨가기를 바라는 대신 이 순서대로 처리한다.
1단계: AAAA 질의에 어느 리졸버가 응답했는지 증명한다
Android에는 하나의 DNS 경로만 있지 않다. VPN은 자기 네트워크용 DNS 서버를 밀어 넣을 수 있고, 시스템은 적합하다고 판단한 호스트 이름에 Private DNS를 쓸 수 있으며, Chrome은 chrome://settings/security에서 자체 암호화 리졸버를 돌릴 수 있다. 터널이 10.61.0.1을 밀어 넣지만 Private DNS가 공용 공급자를 가리키는 휴대폰에서는 사설 영역 안의 이름이 그 영역을 소유한 기계에서 해석되지 않는다. 브라우저는 그 이름에 대한 권한이 없는 리졸버에서 답을 받고 이를 없는 것으로 취급하며, 오류 문구에는 DNS가 전혀 나오지 않는다.
다른 무엇을 바꾸기 전에 터널의 리졸버에 직접 물어본다. 처음 두 명령은 Android가 활성 네트워크에 어떤 DNS 서버를 붙였는지와 Private DNS가 자동인지 엄격인지를 보여준다. 세 번째는 휴대폰이 아니라 터널 안의 아무 클라이언트에서 실행한다.
adb shell dumpsys connectivity | grep -A3 "DnsAddresses"
adb shell settings get global private_dns_mode
nslookup -type=AAAA nas.lan 10.61.0.1
2단계: VpnService가 실제로 무엇을 선언했는지 읽는다
Android 쪽은 추론이 아니라 명시적이다. VpnService.Builder는 addAddress로 주소를, addRoute로 경로를, addDnsServer로 DNS 서버를 추가하며, allowFamily로 터널 전체를 하나의 주소 패밀리로 제한할 수 있다. allowFamily(AF_INET)를 호출한 뒤 나중에 IPv6 경로를 추가한 빌더는 시스템에 상반된 두 가지를 말한 것이고, 시스템이 유지하는 것은 패밀리 제한이다. fd00:61::/64를 덮는 경로에 다른 프리픽스의 인터페이스 주소를 조합하면 비슷한 이유로 실패한다. 주소는 존재하지만 그 경로에 대해 on-link가 아니다.
앱의 토글을 믿는 대신 선언을 읽는다. dumpsys netd는 tun 인터페이스에 붙은 주소와 경로를 출력하고, dumpsys connectivity 안의 네트워크별 LinkProperties는 네트워크가 주장하는 패밀리 옆에 DNS 목록을 보여준다.
adb shell ip -6 addr show tun0
adb shell ip -6 route show table all | grep tun0
adb shell dumpsys connectivity | grep -E "LinkAddresses|Routes"
tun 인터페이스에 IPv6 주소가 있는데 서비스 프리픽스와 일치하는 경로가 하나도 없다면, 브라우저가 개입하기 전에 이미 IPv6 전달이 깨진 것이다. 이것은 시도를 거부하는 브라우저와 다른 버그이고 다른 수정이 필요하다.
3단계: IPv6를 시도할지 말지는 브라우저가 정한다
Chromium은 받은 모든 AAAA 레코드를 쓰지 않는다. 호스트 리졸버에는 IPv6 도달 가능성 검사와 후보 필터링이 있어, 네트워크가 그것을 실어 나를 수 없다고 판단되면 IPv6를 버리거나 우선순위를 낮출 수 있고, 리졸버 소스는 그 검사와 주변 조건을 함께 보여준다. 논리는 버전과 플래그에 따라 달라지며, 같은 터널의 두 휴대폰이 다르게 동작하는 이유가 바로 여기에 있다. 후보가 생기면 Happy Eyeballs가 연결마다 패밀리를 경쟁시키므로, 느리게 응답하는 IPv6 경로는 빠르게 응답하는 IPv4에 여전히 질 수 있다.
추론하지 말고 판단을 관찰한다. chrome://net-export로 기록해 netlog 뷰어에서 재생한 세션은 각 해석과 선택된 패밀리를 보여준다. chrome://net-internals/#dns는 캐시된 결과를 보여준다. 자기 리졸버에는 AAAA가 있는 이름에 대해 A 레코드만 돌려주는 해석은 이 단계를 곧바로 가리킨다.
4단계: 패킷이 왕복할 수 있는지 확인한다
주소 선택은 전달이 아니다. Android는 네트워크별 라우팅 테이블과 정책 규칙을 유지하며 fwmark로 테이블을 고르므로, 메인 테이블에 보이는 경로가 소켓이 쓰는 경로와 같다고 볼 수 없다. 규칙을 읽고 커널에 소스 주소에 대한 목적지를 해석하도록 요청하는 것이 이 둘을 분리한다.
adb shell ip -6 rule show
adb shell ip -6 route get fd00:61::20 from fd00:61::10
질문에 답하는 것은 route get의 출력이다. tun0를 지목하는데도 패킷이 죽는다면 휴대폰으로 돌아가지 말고 터널 MTU와 게이트웨이의 IPv6 포워딩으로 넘어간다.
::/0을 추가하면 대개 더 나빠지는 이유
::/0를 추가하면 터널의 주장이 하나의 프리픽스에서 모든 IPv6 목적지로 바뀐다. 휴대폰에서는 도달 가능성 검사를 만족시킬 수 있다. 프리픽스 밖의 테스트 주소가 이제 라우팅 가능해 보이기 때문이다. 그러나 게이트웨이에 IPv6 업스트림 용량이 생기는 것은 아니고, 많은 가정용 게이트웨이에는 동작하는 IPv6 업링크가 아예 없다. 결과는 공용 IPv6에 대한 블랙홀이다. 예전에는 IPv4로 떨어지던 이름이 이제 멈추고, 사설 페이지가 더 좋아 보이는 것은 검사가 다른 것을 재기 때문일 뿐이다.
정책 비용도 있다. 터널에 ::/0이 있으면 모든 IPv6 트래픽이 VPN으로 나가며, 네트워크 소유자가 실어 나르는 데 동의한 적 없는 트래픽도 포함된다. 이것은 이름이 붙은 책임자가 있는 배포 결정으로 다루어야 하며, 새벽 1시에 하는 디버깅 단계가 아니다.
이 일을 끝내는 작업 순서
AAAA 응답이 영역을 가진 리졸버에서 오는지 확인하고, tun 인터페이스의 VpnService와 netd 상태를 읽고, Chromium이 후보를 버렸는지 보려고 net-export 추적을 남긴 뒤, ip -6 route get로 왕복을 시험한다. 실제로 실패한 계층을 고치고 분할 정책은 건드리지 않는다. 사설 이름에 공용 이름과 다른 답이 필요하다면 split tunneling이 바로 그 용도다.
휴대폰 빌드, 브라우저 버전, 그리고 질의한 리졸버를 기록한다. 이 세 가지 사실이 "IPv6 is broken"을 다음 사람이 재현할 수 있는 무언가로 바꾼다.