كلّفتني أربعة افتراضات حول 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 وما بعده لا يمكنك تسمية إلا الحزم التي تراها، لذا فقائمة استبعاد مبنية على استعلام لا يُسمح لتطبيقك بتشغيله ستُفوّت مدخلات بصمت. قارن القائمة بما يعيده مدير الحزم فعليًا قبل أن تثق بها.

تقسم ملفات تعريف العمل الجمهور مرة أخرى. النفق الذي يبدأ داخل الملف الشخصي يغطي ذلك الملف وتطبيقاته، بينما يحتفظ الجانب الشخصي باتصاله الخاص. هذا قرار نطاق يُتخذ مرة واحدة، ومكانه الواجهة لا تذكرة دعم.

الخرافة 4: stopSelf يُنهي النفق

لا يُنهيه. تعيش الواجهة في الواصف الذي يعيده establish()، ويمكن لكائن الخدمة أن يموت بينما يبقى ذلك الواصف مفتوحًا. أغلق ParcelFileDescriptor أولًا، وأسقط المرجع، وضمّ خيطي القراءة والكتابة أو قاطعهما، ثم استدعِ stopForeground(true) و stopSelf() بهذا الترتيب.

الواصف المنسي هو السبب المعتاد وراء ظهور خدمة كأنها تنجو من طلب إيقافها، وهو يفسّر مسارًا قديمًا يعيش أطول من التطبيق الذي أضافه.

الخطأ المعاكس شائع بالقدر نفسه: إغلاق الواصف بينما خيط قراءة في منتصف القراءة، ثم ترك ذلك الخيط ليعالج استثناءً لم يكتبه أحد. أبقِ تسلسل الإغلاق في مكان واحد واجعل فعل المستخدم و onRevoke() يستدعيان إليه.

ما يجب فحصه عندما يبدو النفق ميتًا

احمِ المقابس طويلة العمر بـ protect() قبل أن ينهض النفق. من دونه يُعاد توجيه نقل النفق نفسه إلى داخل النفق ويتوقف الاتصال بطريقة تبدو تمامًا كعطل في الخادم. اختبر مقابل عنوان لا يوجد إلا خلف الشبكة البعيدة، لا موقع عام يرد من الذاكرة المؤقتة أو من المسار الأساسي.

سجّل نتيجة establish()، والمسارات، والحزم المستبعدة، وسبب الإغلاق. تلك الأسطر الأربعة تفصل مشكلة موافقة عن مشكلة نطاق عن مشكلة دورة حياة دون التقاط حزم.

مقالات ذات صلة