두 경로가 Android 앱 앞에 IP 인터페이스를 놓을 수 있고, 둘 다 소유자가 매우 다른 같은 종류의 파일 디스크립터에서 끝난다. 하나는 VpnService와 그 빌더를 지나고, 다른 하나는 root 셸과 /dev/net/tun을 지난다. 그 디스크립터에 대한 모든 읽기는 보내는 패킷이고 모든 쓰기는 받는 패킷이다. 경로를 가르는 것은 정책을 누가 쥐고 있는지, 누가 OS 업그레이드를 버티는지, 라우팅 테이블의 얼마나를 건드릴 수 있는지다.

플랫폼 경로를 택하고, 그것이 거부하는 구체적인 일을 지목할 수 있을 때만 다른 길을 간다. 그 거부는 보통 TAP이거나, 자기 앱 집합 밖에서의 캡처, 혹은 프레임워크가 결코 노출하지 않는 라우팅 규칙이다.

플랫폼 경로에서는 명령이 아니라 의도를 선언한다. 주소를 하나 이상, 원하는 경로, DNS 서버, 선택적인 앱 목록을 넘기면 Android 시스템 서비스가 자기 신원으로 특권 설정을 수행한다. 인터페이스 이름을 미리 알지 못하고, 그것을 만드는 ioctl도 내보내지 않으며, 바로 그게 요점이다.

IP 인터페이스를 얻는 두 가지 방법

root 경로는 더 오래된 방법이다. /dev/net/tun을 열고, IFF_TUN으로 TUNSETIFF를 호출하고, ip link로 인터페이스를 올리고, 주소와 경로를 손으로 추가한다. 프레임워크가 조용히 처리했을 단계까지 모든 단계를 직접 제대로 해야 한다. 경로 하나가 빠지면 사용자에게는 서버가 죽은 것과 같은 보고가 간다.

디버깅도 설정만큼 다르다. root 경로에서는 인터페이스가 존재하거나 ioctl이 직접 읽을 수 있는 오류를 돌려준다. 플랫폼 경로에서는 establish()가 성공한 뒤에야 인터페이스가 존재하므로, 실패는 들쑤실 수 있는 장치 노드가 아니라 예외나 null 디스크립터로 온다.

establish()가 실제로 무엇을 돌려주는가

Builder.establish()에는 세 가지 결과가 있고 각각 다른 대응이 필요하다. 디스크립터는 인터페이스가 구성되어 사용할 수 있다는 뜻이고, null은 요청이 통과하지 못했다는 뜻이며, 던져진 예외는 시스템 서비스가 거부했다는 뜻이다. null과 성공을 하나의 boolean으로 합치는 것은 패킷을 전혀 나르지 않는 터널을 앱이 작동 중이라고 보고하는 가장 흔한 방식이다. Builder 참조는 주소 요건을 밝히고 있으며, 서버를 탓하기 전에 읽어볼 만하다.

방향은 디스크립터가 정하며 프로토콜이 정하지 않는다. 읽기는 VPN용으로 이미 선택된 보내는 패킷을 주고, 쓰기는 받는 패킷을 다시 인터페이스로 주입한다. 그 패킷을 암호화하고 인증하고 프레이밍하는 것은 자기 트랜스포트의 일인데, 플랫폼은 패킷 경계에서 멈추기 때문이다.

Binder는 한 홉, JNI는 또 다른 축

빌더 요청은 Binder를 통해 시스템으로 들어가고, 그 아래 어딘가에서 플랫폼이 장치를 구성하려고 네이티브 코드를 호출한다. 한 번의 호출 안에 쌓인 두 가지 다른 메커니즘이다. Binder는 앱과 시스템 서비스 사이의 프로세스 간 통신이고, JNI는 프로세스 내부에서 Java에서 네이티브로 가는 다리다. Android 9 AOSP 트리를 읽으면 스택이 문자 그대로 보이며, 내부 클래스 이름과 파일 경로가 릴리스 사이에 옮겨 다닌다는 점을 기억할 만하다.

옮겨 다니지 않는 것은 의지할 수 있는 계약, 곧 빌더와 그것이 돌려주는 디스크립터, 그 주변의 서비스 수명 주기다. 어떤 앱도 인터페이스를 얻으려고 내부 서비스 트랜잭션을 호출하거나 장치 노드를 다시 만들면 안 된다. 코드가 AOSP 클래스 이름에 의존하면 시험하지 않은 릴리스에서 깨진다.

TUN은 IP를, TAP은 Ethernet을 나른다

TUN은 IP 인터페이스다. TAP은 Ethernet 인터페이스이고, 그 차이가 브리징이 가능한지를 결정한다. TUN 장치에는 MAC 주소도, 전달할 Ethernet 헤더도 없으므로 경로를 어떻게 쓰든 TUN 클라이언트는 계층 2 브리지에 들어갈 수 없다.

VpnService는 IP 변형을 주며, 이것이 앱 수준 VPN이 필요로 하고 원시 Ethernet 스택 없이 지원할 수 있는 것이다. 설계가 브리징을 요구하면 플랫폼 경로로는 거기에 닿지 못하고, root 경로도 커널과 장치 권한이 협조할 때만 닿는다.

root 경로가 여전히 이기는 곳

세 가지 작업이 사람들을 플랫폼 경로에서 밀어낸다. 계층 2 브리징에는 TAP이 필요하다. 자기 VPN 범위 밖 앱의 트래픽을 잡으려면 프레임워크가 주지 않는 가시성이 필요하다. 빌더가 결코 노출하지 않는 정책 라우팅, 예컨대 목적지 하나를 위한 별도 테이블에는 직접 설치하는 규칙이 필요하다.

비용은 나중에 온다. 잠금 해제된 bootloader나 root된 이미지, 발밑에서 움직이는 SELinux 정책, OTA 후에 제대로 동작하지 않는 장치이며, root된 방법은 일반 앱 배포로도 내보낼 수 없으므로 대부분의 팀은 제품이 아니라 내부 진단 경로로 유지한다.

둘 중에서 고르기

가진 코드가 아니라 필요한 인터페이스에서 시작한다. 답이 앱 범위 경로를 가진 TUN 장치라면 플랫폼 경로가 더 짧고 업그레이드를 버틴다. 답이 Ethernet 프레임, 다른 앱의 캡처, 빌더가 거부하는 라우팅 규칙을 포함한다면 root 영역이며 운영 비용으로 예산을 잡아야 한다.

대부분의 논쟁을 끝내는 점검: 먼저 플랫폼 경로를 만들고, establish() 결과를 원격 세션 상태와 분리해 기록하고, 각 방향으로 패킷 하나를 확인한다. 그런 다음에야 빠진 기능이 지원되는 경로를 떠날 이유가 되는지 판단한다.

관련 글