Vì sao tunnel Android VpnService của bạn ngừng định tuyến lưu lượng
Bốn niềm tin về VpnService sụp đổ trong môi trường thật: một tunnel tại một thời điểm, phạm vi tuyến, danh sách theo ứng dụng và thứ tự tắt, kèm một bước kiểm tra cho mỗi điều.
Bốn giả định về Android VpnService từng khiến tôi mất gần cả tuần, và không giả định nào liên quan đến giao thức mạng. Ứng dụng báo đã kết nối, biểu tượng chìa khóa nằm trên thanh trạng thái, mà lưu lượng vẫn đi ra theo tuyến thường. Tôi cứ cho rằng tunnel là của mình cho đến khi tôi dừng nó, rằng các tuyến tôi thêm là các tuyến tôi nhận được, và rằng gọi stopSelf sẽ kéo cả giao diện xuống theo. Dịch vụ đang hành xử đúng như tài liệu. Phần hỏng là mô hình trong đầu tôi.
Niềm tin 1: tunnel vẫn sống cho đến khi bạn dừng nó
Android giữ một giao diện VPN tại một thời điểm. Dựng cái thứ hai lên và nền tảng không xếp hàng nó: nó vô hiệu hóa cái của bạn và gọi onRevoke() trên dịch vụ của bạn. Nếu mã của bạn chỉ tháo dỡ khi bấm nút, một lần chiếm quyền sẽ để lại một bộ mô tả và một luồng đọc, và lần khởi động sau thừa hưởng cả hai.
Tài liệu tham chiếu VpnService nói rõ rằng prepare() trả về null khi ứng dụng đã có sự đồng ý và một Intent khi người dùng vẫn phải phê duyệt. Sự đồng ý đó chỉ đến nếu bạn chuyển intent qua startActivityForResult(). Bỏ qua bước chuyển giao và establish() trả về null, thứ được đọc như một thất bại âm thầm chứ không phải vấn đề quyền.
Always-on VPN thêm một chủ sở hữu mà bạn không kiểm soát. Hệ thống có thể đưa tunnel trở lại sau khi khởi động hoặc sau một lần sụp, và nó chen trước một ứng dụng VPN mà người dùng tự khởi động. Hãy xây onRevoke() để nó đóng đúng những tài nguyên mà lần tắt thường của bạn đóng, và coi đó là đường dừng chính chứ không phải trường hợp lề. Người dùng đổi ứng dụng VPN; mã của bạn phải sống sót qua chuyện đó.
Niềm tin 2: addRoute bắt đúng những gì bạn liệt kê
Các tuyến quyết định gói nào đến được bộ mô tả của bạn, và một lỗi về phạm vi tạo ra trường hợp phát điên khi một phần lưu lượng đi qua tunnel còn một phần thì không. addRoute("0.0.0.0", 0) lấy toàn bộ IPv4, nghĩa là mọi gói giờ đều đến chỗ bạn và phải được chuyển tiếp hoặc bỏ. Một /32 cho một máy chủ duy nhất gần như không lấy gì, và mọi thứ khác hành xử như thể VPN của bạn chưa từng được cài.
Các họ địa chỉ được đếm riêng. Một tuyến chỉ IPv4 để IPv6 ở lại trên mạng bên dưới, nên một máy chủ hai chồng vẫn có thể phân giải và kết nối ra ngoài tunnel trong khi bên trong trông như đã kết nối hoàn hảo.
DNS theo cùng logic. Các máy chủ được thêm bằng addDnsServer() đi đến những ứng dụng dùng bộ phân giải của tunnel, trong khi một ứng dụng có điểm cuối DNS mã hóa riêng hoặc Private DNS đặt trên một tên máy chủ sẽ phớt lờ chúng hoàn toàn. Khi tên phân giải thất bại với một ứng dụng và thành công với ứng dụng khác, hãy kiểm tra đường đi của bộ phân giải trước khi chạm vào dù chỉ một tuyến.
Niềm tin 3: quy tắc theo ứng dụng là một công tắc có thể gạt cả hai chiều
addDisallowedApplication() và addAllowedApplication() là hai chế độ, không phải hai bộ lọc. Bộ dựng chỉ nhận một mô hình cho mỗi tunnel, nên một ứng dụng muốn mọi thứ trừ ba gói sẽ dùng danh sách cấm và để danh sách cho phép trống. Hai thứ loại trừ nhau, và người ta phát hiện điều đó lúc chạy sau khi màn hình cài đặt đã đưa ra cả hai công tắc.
Khả năng hiển thị gói cũng cắn. Từ Android 11 trở đi bạn chỉ có thể nêu tên những gói bạn nhìn thấy, nên một danh sách loại trừ dựng từ truy vấn mà ứng dụng của bạn không được phép chạy sẽ âm thầm bỏ sót mục. Hãy so danh sách với thứ trình quản lý gói thực sự trả về trước khi tin vào nó.
Hồ sơ công việc lại chia đôi khán giả. Một tunnel khởi động trong hồ sơ bao phủ hồ sơ đó và các ứng dụng của nó, còn phía cá nhân giữ kết nối riêng. Đó là một quyết định phạm vi đưa ra một lần, và chỗ của nó là ở giao diện chứ không phải trong một phiếu hỗ trợ.
Niềm tin 4: stopSelf kết thúc tunnel
Không. Giao diện sống trong bộ mô tả mà establish() trả về, và đối tượng dịch vụ có thể chết trong khi bộ mô tả đó vẫn mở. Hãy đóng ParcelFileDescriptor trước, buông tham chiếu, join hoặc ngắt các luồng đọc và ghi, rồi gọi stopForeground(true) và stopSelf() theo thứ tự đó.
Một bộ mô tả bị bỏ quên là lý do thường gặp khiến một dịch vụ trông như sống sót qua chính yêu cầu dừng của nó, và nó giải thích một tuyến cũ sống lâu hơn ứng dụng đã thêm nó.
Lỗi ngược lại cũng phổ biến không kém: đóng bộ mô tả khi một luồng đọc đang đọc dở, rồi để luồng đó xử lý một ngoại lệ mà không ai viết mã cho nó. Hãy giữ trình tự tắt ở một chỗ và để cả hành động của người dùng lẫn onRevoke() gọi vào đó.
Cần kiểm tra gì khi tunnel trông như đã chết
Hãy bảo vệ các socket sống lâu bằng protect() trước khi tunnel đi lên. Không có nó, chính phần vận chuyển của tunnel bị định tuyến ngược vào tunnel và kết nối kẹt theo cách trông đúng như lỗi máy chủ. Hãy thử với một địa chỉ chỉ tồn tại sau mạng từ xa, không phải một trang công khai trả lời từ bộ nhớ đệm hoặc từ đường đi bên dưới.
Ghi lại kết quả của establish(), các tuyến, các gói bị loại và lý do tắt. Bốn dòng đó tách vấn đề đồng ý khỏi vấn đề phạm vi và khỏi vấn đề vòng đời mà không cần bắt gói.