มีครั้งหนึ่งผมเสียเวลาเกือบทั้งสัปดาห์ไปกับสมมติฐานสี่ข้อเกี่ยวกับ 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 อยู่บนเครือข่ายพื้นฐาน โฮสต์แบบ dual-stack จึงยังแก้ชื่อและเชื่อมต่อนอกทันเนลได้ ทั้งที่ภายในดูเหมือนเชื่อมต่อสมบูรณ์

DNS ก็ใช้ตรรกะเดียวกัน เซิร์ฟเวอร์ที่เพิ่มด้วย addDnsServer() จะส่งไปยังแอปที่ใช้ตัวแก้ไขของทันเนล ขณะที่แอปที่มีปลายทาง DNS เข้ารหัสของตัวเองหรือตั้ง Private DNS ไว้ที่ชื่อโฮสต์จะเพิกเฉยต่อเซิร์ฟเวอร์เหล่านั้นทั้งหมด เมื่อชื่อใช้ไม่ได้กับแอปหนึ่งแต่ใช้ได้กับอีกแอป ให้ตรวจเส้นทางของตัวแก้ไขก่อนจะแตะเส้นทางแม้แต่เส้นเดียว

ความเชื่อที่ 3: กฎแยกตามแอปเป็นสวิตช์ที่พลิกได้ทั้งสองทาง

addDisallowedApplication() และ addAllowedApplication() เป็นสองโหมด ไม่ใช่สองตัวกรอง ตัวสร้างรับได้เพียงโมเดลเดียวต่อทันเนล แอปที่ต้องการทุกอย่างยกเว้นสามแพ็กเกจจึงใช้รายการห้ามและปล่อยรายการอนุญาตให้ว่าง ทั้งสองอย่างตัดกันเอง และผู้คนจะรู้เรื่องนี้ตอนรันไทม์หลังจากหน้าจอตั้งค่าเสนอสวิตช์ทั้งสองไปแล้ว

การมองเห็นแพ็กเกจก็กัดเช่นกัน ตั้งแต่ Android 11 คุณตั้งชื่อได้เฉพาะแพ็กเกจที่มองเห็นได้ รายการยกเว้นที่สร้างจากคิวรีซึ่งแอปของคุณไม่ได้รับอนุญาตให้รันจะพลาดรายการไปอย่างเงียบ ๆ ก่อนจะเชื่อรายการนั้น ให้เทียบกับสิ่งที่ตัวจัดการแพ็กเกจคืนมาจริง

โปรไฟล์งานแบ่งผู้ใช้อีกครั้ง ทันเนลที่เริ่มภายในโปรไฟล์จะครอบคลุมโปรไฟล์นั้นและแอปของมัน ขณะที่ฝั่งส่วนตัวยังคงการเชื่อมต่อของตัวเอง นี่คือการตัดสินใจเรื่องขอบเขตที่ทำครั้งเดียว และที่ของมันคือในอินเทอร์เฟซ ไม่ใช่ในตั๋วสนับสนุน

ความเชื่อที่ 4: stopSelf จบการทำงานของทันเนล

ไม่ใช่ อินเทอร์เฟซอาศัยอยู่ในตัวอธิบายที่ establish() คืนมา และออบเจกต์ของบริการตายได้ขณะที่ตัวอธิบายนั้นยังเปิดอยู่ ปิด ParcelFileDescriptor ก่อน ปล่อยการอ้างอิง แล้ว join หรือขัดจังหวะเธรดอ่านและเขียน จากนั้นจึงเรียก stopForeground(true) และ stopSelf() ตามลำดับนั้น

ตัวอธิบายที่ถูกลืมคือสาเหตุปกติที่ทำให้บริการดูเหมือนอยู่รอดจากการขอหยุดของตัวเอง และมันอธิบายเส้นทางเก่าที่อายุยืนกว่าแอปที่เพิ่มมันเข้าไป

ความผิดพลาดตรงข้ามก็พบได้บ่อยพอ ๆ กัน คือปิดตัวอธิบายขณะที่เธรดอ่านกำลังอ่านอยู่กลางคัน แล้วปล่อยให้เธรดนั้นจัดการข้อยกเว้นที่ไม่มีใครเขียนโค้ดรองรับไว้ เก็บลำดับการปิดไว้ที่เดียว และให้ทั้งการกระทำของผู้ใช้และ onRevoke() เรียกเข้ามาที่จุดนั้น

สิ่งที่ควรตรวจเมื่อทันเนลดูเหมือนตาย

ปกป้องซ็อกเก็ตที่อายุยืนด้วย protect() ก่อนทันเนลจะขึ้น ถ้าไม่มี เส้นทางขนส่งของทันเนลเองจะถูกกำหนดเส้นกลับเข้าไปในทันเนล และการเชื่อมต่อจะค้างในลักษณะที่ดูเหมือนความผิดพลาดของเซิร์ฟเวอร์ทุกประการ ทดสอบกับที่อยู่ที่มีอยู่เฉพาะหลังเครือข่ายระยะไกล ไม่ใช่เว็บไซต์สาธารณะที่ตอบจากแคชหรือจากเส้นทางพื้นฐาน

บันทึกผลของ establish() เส้นทาง แพ็กเกจที่ยกเว้น และเหตุผลในการปิด สี่บรรทัดนั้นแยกปัญหาความยินยอมออกจากปัญหาขอบเขตและปัญหาวงจรชีวิตได้โดยไม่ต้องดักแพ็กเก็ต

บทความที่เกี่ยวข้อง