터널이 올라오고 키 아이콘이 나타나는데 브라우저의 모든 요청은 여전히 시간 초과한다. VpnService 앱에서 이 조합은 보통 읽기 루프는 살아 있고 출력 절반이 깨졌다는 뜻이며, 서버가 핸드셰이크를 거부한 것은 아니다. 안드로이드는 이미 인터페이스를 만들고 앱 트래픽을 그 안으로 옮겼으며 프로세스에 파일 디스크립터를 넘겼다. 그 디스크립터 이후의 모든 것은 당신의 코드이고, 호출 하나가 빠지는 것만으로 전체 스트림이 삼켜진다.

시스템이 주는 것과 주지 않는 것

establish()는 tun 인터페이스용 ParcelFileDescriptor를 반환한다. 시스템은 나가는 IP 패킷을 그 안에 쓰고, 당신의 프로세스가 그것을 읽어 어디로 갈지 결정하고 일반 소켓으로 보내기를 기대한다. 아래에 커널 포워딩은 없으므로 아무도 읽지 않으면 패킷은 버려지고 인터페이스 카운터는 움직이지 않는다.

추측 대신 세는 것부터 시작하라. 성공적인 읽기마다 카운터를 올리고 처음 몇 패킷의 목적지와 프로토콜을 기록하라. 트래픽이 생성되는데도 카운터가 영에 머물면 결함은 포워딩 코드 위쪽에 있고, 수색 범위는 builder 구성과 읽기 모드로 좁혀진다. 카운터는 움직이는데 브라우저가 실패하면 결함은 보내는 경로나 돌아오는 경로에 있다.

블로킹 읽기가 루프의 진행을 결정한다

setBlocking(true)는 디스크립터를 블로킹 모드로 두며 API 21부터 존재한다. 구버전에서는 원시 디스크립터에 fcntl로 O_NONBLOCK을 지우는 작은 JNI 도우미가 우회책이다. 모드가 중요한 이유는 대기 패킷이 없을 때 읽기가 무엇을 할 수 있는지 결정하기 때문이다.

비블로킹 모드에서 그 읽기는 즉시 음수 값으로 돌아오고, 결과를 무시하는 루프는 트래픽을 전혀 옮기지 않고 코어만 돌린다. 블로킹 모드에서 읽기는 기다리며 이것이 포워딩 스레드가 원하는 동작이지만, 그때는 스레드를 중단할 수 없다. 읽기를 실패하게 만드는 지원되는 방법은 디스크립터를 닫는 것이고, 그래야 루프가 빠져나올 수 있다. 프로세스를 죽이거나 스레드를 중단하면 다음 시작이 인터페이스와 맞지 않는 디스크립터를 들고 있을 수 있다.

호출마다 인터페이스 MTU 크기의 버퍼로 패킷 하나를 읽고, 영이나 음수 반환은 빈 패킷이 아니라 오류로 다뤄라.

protect()는 첫 전송 전에, 후가 아니라

캡슐화된 패킷을 나르는 모든 소켓은 먼저 보호되어야 한다. 보호되지 않은 소켓은 방금 설치한 라우팅 규칙을 그대로 따르므로 패킷이 터널로 되돌아가고 당신의 읽기 루프가 다시 집어 간다. 그러면 터널은 자기 출력을 끝없이 포워딩하고, 밖에서는 열리기만 하고 응답하지 않는 연결처럼 보인다.

순서는 protect, 그다음 connect 또는 bind, 그다음 send다. connect 후에 protect를 호출하면 이전 경로 결정이 남아 호출이 true를 반환해도 소켓은 잘못된 경로를 계속 쓴다. 반환된 불리언을 확인하고 false면 동작할 수 없는 전송으로 들어가는 대신 크게 실패하라.

protect()를 한 번도 건드리지 않은 소켓에서 sendto가 내는 권한 오류가 이 실수의 흔한 징후다.

UDP 예제는 HTTPS를 나르지 않는다

대부분의 튜토리얼이 복사하는 포워딩 예제는 UDP만 처리한다. TCP는 핸드셰이크, 시퀀스 번호, 확인 응답, 재전송이 필요하고 tun 디스크립터와 소켓 사이에서 페이로드를 복사하는 루프는 그중 아무것도 제공하지 않는다. 그래서 UDP DNS 질의는 통과하는데 모든 HTTPS 요청은 멈춘다. 사용자 공간 TCP 스택을 돌리거나, 기기에서 TCP를 종료하고 스트림을 포워딩하는 로컬 프록시에 연결을 넘겨라.

고쳐 쓰기 전에 분리부터 시험하라. UDP 질의 하나를 보내고 같은 호스트로 TCP 연결 하나를 열어라. UDP 경로는 되고 TCP 경로가 첫 SYN에서 멈춘다면 문제는 포워딩 코드이지 서버가 아니다. 그 한 번의 비교가 쓸모없는 서버 로그를 읽는 시간을 크게 줄여 준다.

경로 목록과 앱별 필터가 보이는 범위를 정한다

addAddress, addRoute, addDns는 시스템이 터널로 무엇을 보내는지 정한다. 경로 목록 밖의 목적지는 디스크립터에 절대 도달하지 않는다. 그래서 아무리 포워딩 로직을 더해도 고쳐지지 않는다. 당신이 하는 builder 호출을 기록하고 앱이 실제로 도달하려는 주소와 비교하라.

앱별 선택에는 자체 하한이 있다. addAllowedApplication과 addDisallowedApplication은 API 21이 필요하고 구버전에는 그 메서드가 아예 없다. 패키지로 거르는 설계는 거기서 전혀 돌 수 없다.

재설정은 낡은 디스크립터를 남긴다

establish()를 다시 호출하면 새 디스크립터가 반환되고 이전 인터페이스가 닫힌다. 옛 디스크립터는 이제 무효이므로 거기서 아직 블록된 스레드는 오류를 받고, 옛 경로용으로 만든 소켓은 교체가 필요할 수 있다. onRevoke는 사용자나 시스템이 가져간 터널의 콜백이다. 재설정 전에 읽는 쪽을 멈추는 식으로 수명 주기를 한 소유자에게 두면 연결됐지만 죽은 상태의 대부분을 막는다.

오류는 속한 단계별로 읽어라. recvfrom의 연결 재설정은 코드가 아직 읽는 동안 상대가 닫았다는 뜻이다. 잘못된 주소 오류는 헤더 파싱이나 오프셋 버그를 가리키며 네트워크 문제가 아니다.

작업 순서는 블로킹 모드 켜기, 읽기마다 패킷 하나, 헤더 파싱, 소켓 보호, 페이로드 전송, 인계 시 깔끔한 종료, 목적지를 덮는 경로 목록이다.

관련 글