Android VPN 터널에서 서브넷을 제외하는 방법
VpnService는 경로를 제외할 수 없으므로 보수를 추가합니다. /8에 대해 생성한 8개의 프리픽스와 나머지를 잡아내는 IPv6, DNS, 조회 점검을 함께 사용합니다.
Android의 VpnService에는 제외 경로가 없고, API를 아무리 읽어도 하나가 생기지 않습니다. Builder.addRoute()는 터널에 목적지만 추가하므로, LAN 세그먼트를 로컬로 유지한다는 것은 그 세그먼트가 경로 목록에 전혀 들어가지 않는다는 뜻입니다. 대신 추가하는 것은 세그먼트의 보수이며, 일반 프리픽스로 표현합니다. 작업은 산술이고, 몇몇 예측 가능한 지점에서 실패합니다.
아래 점검을 순서대로 진행합니다. 각 점검은 이전 점검이 성립했다고 가정하므로, 아래쪽 증상을 덧대지 말고 첫 실패에서 멈춥니다.
터널이 주장하는 것에서 시작합니다
0.0.0.0/0를 addRoute()에 전달하면 Android는 모든 IPv4 목적지가 터널에 속한다고 판단합니다. 많은 클라이언트가 분할 터널링을 못하는 것처럼 보이는 이유는 전체 주소 공간을 주장한 뒤 거기서 빼려고 하기 때문입니다. 빌더 안에서 빼는 것은 없습니다. 로컬로 남기고 싶었던 네트워크에 대한 더 구체적인 경로는 가장 긴 일치 프리픽스가 이기고 그 네트워크가 터널 안으로 들어가기 때문에 기대와 반대로 작동합니다.
경로 목록이 곧 전체 정책입니다. 그 뒤에 목적지별 거부권은 없고, Builder 참조에도 그것을 제공하는 메서드가 없습니다.
프리픽스 산술로 보수를 생성합니다
제외된 프리픽스의 보수는 주소 트리의 루트로 가는 경로 위에 있는 형제들의 집합입니다. 1부터 제외된 길이까지 각 길이마다 해당 길이의 블록 중 프리픽스를 포함하는 것을 고르고, 프리픽스 바로 아래 비트를 뒤집어 결과를 출력합니다. 실행해 보기 전에는 추상적으로 들립니다.
10.0.0.0/8를 제외하면 128.0.0.0/1, 64.0.0.0/2, 32.0.0.0/3, 16.0.0.0/4, 0.0.0.0/5, 12.0.0.0/6, 8.0.0.0/7, 11.0.0.0/8의 여덟 개 경로가 나옵니다. 어느 것도 10.0.0.0/8를 포함하지 않으며, 합치면 나머지 전부를 덮습니다.
여덟 줄이면 어떤 프리픽스든 그 목록을 생성합니다:
import ipaddress
def complement(addr, length):
base = int(ipaddress.IPv4Address(addr))
out = []
for k in range(1, length + 1):
block = base & (0xFFFFFFFF << (32 - k))
sibling = block ^ (1 << (32 - k))
out.append(f"{ipaddress.IPv4Address(sibling)}/{k}")
return out
print(complement("10.0.0.0", 8))
경로 수는 프리픽스 길이와 같고, 그것이 지켜볼 숫자입니다. /21 제외 하나는 21개의 경로를 쓰고 읽을 만하게 유지됩니다. 제외가 열 개가 되면 수백 개가 될 수 있으며, 보통은 생성기가 아니라 네트워크 설계를 바꿔야 한다는 뜻입니다.
주소 패밀리와 터널 범위를 확인합니다
IPv4 경로는 IPv6에 아무 효과가 없습니다. 터널이 IPv6 주소를 주장하면 IPv6 LAN 프리픽스에는 자체 보수가 필요하며, 128비트에 대해 같은 산술로 생성합니다. 제외가 서류상으로는 작동하는데 로컬 기기가 닿지 않는 흔한 원인은, 호스트에 IPv6로 도달했고 잘라낸 것은 IPv4뿐이었다는 것입니다. 산술이 틀렸다고 결론 내리기 전에 두 패밀리를 모두 확인합니다.
범위는 점검의 나머지 절반입니다. Android는 네트워크별, 앱별로 라우팅을 추적하므로 같은 프리픽스라도 VPN이 어떻게 선언되었는지에 따라 한 앱에는 터널로, 다른 앱에는 직접으로 갈 수 있습니다.
DNS와 앱별 규칙을 제자리에 둡니다
경로 제외는 리졸버를 바꾸지 않습니다. 로컬 호스트 이름이 터널의 DNS 서버에서 응답되면서 그 주소는 로컬로 라우팅될 수 있고, 불일치는 LAN이 닿을 수 없는 것으로 해석되는 이름으로 나타납니다. 리졸버 정책은 검색 도메인이나 분할 DNS 규칙으로 의도적으로 설정합니다. 경로가 알아서 결정했다고 가정하지 마십시오.
addDisallowedApplication()는 앱 하나를 터널 밖에 두지만, 안에 남는 앱에 대해 목적지를 밖에 두지는 않습니다. protect()는 직접 연 소켓이 터널을 우회하게 하고, 이렇게 터널은 자기 전송을 붙잡지 않습니다. 둘 다 보수를 대신하지 않습니다.
자체 목록이 아니라 조회로 검증합니다
터널이 올라오면 직접 만든 목록을 다시 읽는 대신 주소가 실제로 어디로 가는지 기기에 묻습니다. iproute2를 포함하는 빌드에서는 adb shell ip route get 10.1.2.3가 커널이 고른 인터페이스를 지목합니다. 공인 주소에 대해 같은 명령을 실행하고 터널을 지목하는지 확인합니다.
점검 두 개가 더 있으면 고리가 닫힙니다. 먼저 로컬 이름을 해석하고, 다음으로 해석된 주소에 adb shell ip route get를 실행해 터널이 아닌 인터페이스를 요구하는 것은 성공한 adb shell ping -c 1 host.lan가 도달성만 증명하고 경로는 증명하지 않기 때문입니다. 터널이 IPv6를 주장하면 주소 패밀리 시험을 그 위에서 반복합니다. VPN 네트워크가 라우팅 테이블에 노출되는 방식이 릴리스마다 바뀌었으므로 배포하는 API 수준에서 시험하십시오. 서류상 맞아 보이는 경로 목록과 잘못된 인터페이스를 고르는 조회는 다른 버그이고, 사용자에게 닿는 것은 후자뿐입니다.
제외 목록이 계속 늘어나면 경로 생성을 멈추고 원격 네트워크의 번호를 다시 매기거나 대신 터널을 좁히십시오. 생성기는 빠진 API에 대한 우회책이며 설계 도구가 아닙니다.