Android VpnService에 관한 네 가지 가정 때문에 한때 일주일의 대부분을 날렸습니다. 그중 어느 것도 프로토콜 이야기가 아니었습니다. 앱은 연결됨이라고 했고, 열쇠 아이콘은 상태 표시줄에 앉아 있었고, 트래픽은 여전히 평범한 라우트로 나가고 있었습니다. 저는 터널이 제가 멈출 때까지 제 것이라고, 제가 추가한 라우트가 곧 받는 라우트라고, stopSelf를 부르면 인터페이스도 함께 내려간다고 계속 가정했습니다. 서비스는 문서대로 정확히 동작하고 있었습니다. 깨진 쪽은 제 멘탈 모델이었습니다.

신화 1: 터널은 당신이 멈출 때까지 살아 있다

Android는 한 번에 하나의 VPN 인터페이스만 유지합니다. 두 번째를 세우면 플랫폼은 그것을 대기열에 넣지 않고, 당신의 것을 비활성화한 뒤 당신의 서비스에서 onRevoke()를 호출합니다. 코드가 버튼을 눌러야만 정리한다면, 탈취는 디스크립터와 리더 스레드를 남기고, 다음 시작이 둘 다 물려받습니다.

VpnService 레퍼런스는 앱이 이미 동의를 가졌다면 prepare()가 null을 반환하고, 사용자가 아직 승인해야 한다면 Intent를 반환한다고 분명히 밝힙니다. 그 동의는 인텐트를 startActivityForResult()를 통해 전달할 때만 도착합니다. 전달을 건너뛰면 establish()가 null을 반환하고, 이는 권한 문제가 아니라 조용한 실패로 읽힙니다.

Always-on VPN은 당신이 통제하지 못하는 소유자를 추가합니다. 시스템은 부팅 후나 크래시 후에 터널을 되살릴 수 있고, 사용자가 직접 시작한 VPN 앱을 밀어냅니다. onRevoke()를 평소 종료와 같은 자원을 닫도록 만들고, 이것을 예외 상황이 아니라 주된 중지 경로로 다루십시오. 사용자는 VPN 앱을 갈아탑니다. 코드는 그것을 견뎌야 합니다.

신화 2: addRoute는 열거한 것을 정확히 잡는다

라우트는 어떤 패킷이 당신의 디스크립터에 도달하는지 결정합니다. 범위 오류는 일부 트래픽만 터널을 지나가는 골치 아픈 상황을 만듭니다. addRoute("0.0.0.0", 0)는 모든 IPv4를 가져가므로 이제 모든 패킷이 당신에게 도착하고, 전달하거나 버려야 합니다. 단일 호스트를 위한 /32는 거의 아무것도 가져가지 않으며, 나머지는 VPN이 설치된 적 없는 것처럼 동작합니다.

패밀리는 따로 계산됩니다. IPv4 전용 라우트는 IPv6를 하부 네트워크에 남겨 두므로, 듀얼 스택 호스트는 터널 안에서는 완벽히 연결된 것처럼 보이면서도 밖에서는 여전히 이름을 풀고 연결할 수 있습니다.

DNS도 같은 논리를 따릅니다. addDnsServer()로 추가한 서버는 터널 리졸버를 쓰는 앱으로 갑니다. 반면 자체 암호화 DNS 엔드포인트가 있거나 Private DNS를 호스트 이름으로 설정한 앱은 그것을 완전히 무시합니다. 한 앱에서는 이름이 실패하고 다른 앱에서는 성공한다면, 라우트를 하나라도 건드리기 전에 리졸버 경로를 확인하십시오.

신화 3: 앱별 규칙은 어느 쪽으로든 뒤집을 수 있는 스위치다

addDisallowedApplication()와 addAllowedApplication()는 두 개의 필터가 아니라 두 개의 모드입니다. 빌더는 터널마다 하나의 모델만 받으므로, 세 패키지만 빼고 전부를 원하는 앱은 금지 목록을 쓰고 허용 목록은 비워 둡니다. 둘은 상호 배타적이며, 사람들은 설정 화면이 이미 두 토글을 모두 제시한 뒤에야 런타임에서 그것을 알게 됩니다.

패키지 가시성도 물어뜯습니다. Android 11부터는 볼 수 있는 패키지만 지명할 수 있으므로, 앱이 실행할 수 없는 쿼리로 만든 제외 목록은 조용히 항목을 놓칩니다. 목록을 믿기 전에 패키지 관리자가 실제로 반환하는 것과 비교하십시오.

업무 프로필은 대상을 다시 나눕니다. 프로필 안에서 시작한 터널은 그 프로필과 그 앱을 덮고, 개인 쪽은 자기 연결을 유지합니다. 이는 한 번 결정하는 범위 문제이며, 지원 티켓이 아니라 UI에 속합니다.

신화 4: stopSelf가 터널을 끝낸다

아닙니다. 인터페이스는 establish()가 반환하는 디스크립터 안에 살고, 그 디스크립터가 열린 채로 서비스 객체는 죽을 수 있습니다. 먼저 ParcelFileDescriptor를 닫고, 참조를 놓고, 리더와 라이터 스레드를 join하거나 중단한 다음, stopForeground(true)와 stopSelf()를 그 순서로 호출하십시오.

잊힌 디스크립터는 서비스가 자기 중지 요청을 살아남은 것처럼 보이는 흔한 이유이며, 그것을 추가한 앱보다 오래 사는 낡은 라우트를 설명해 줍니다.

반대 방향의 실수도 흔합니다. 리더 스레드가 읽는 중간에 디스크립터를 닫고, 그 스레드가 아무도 작성하지 않은 예외를 처리하도록 남겨 두는 것입니다. 종료 순서를 한곳에 두고 사용자 동작과 onRevoke()가 모두 그리로 호출하게 하십시오.

터널이 죽은 것처럼 보일 때 확인할 것

터널이 올라오기 전에 오래 사는 소켓을 protect()로 보호하십시오. 그것이 없으면 터널 자체의 전송이 다시 터널로 라우팅되어 연결이 서버 결함과 똑같이 보이는 방식으로 멈춥니다. 원격 네트워크 뒤에만 존재하는 주소로 시험하고, 캐시나 하부 경로에서 응답하는 공개 사이트로 시험하지 마십시오.

establish()의 결과, 라우트, 제외한 패키지, 종료 이유를 기록하십시오. 그 네 줄이면 패킷 캡처 없이 동의 문제와 범위 문제와 수명 주기 문제를 구분할 수 있습니다.

관련 글