كشف VPN على Android: لماذا تُضلّل tun0 وأوامر ping
اسم الواجهة مجرد عُرف؛ وأمر ping يثبت أن مضيفًا واحدًا كان يمكن الوصول إليه. اسأل NetworkCapabilities عمّا تعلنه أي شبكة، ثم تفاعل مع الاستدعاءات الراجعة.
الفحص الذي تستخدمه معظم تطبيقات Android لاكتشاف VPN هو اسم واجهة، ولم يكن يومًا فحصًا لـ VPN. فهو يبلّغ بوجود واجهة ما بالاسم النصي الذي توقّعه المطوّر، وهذا يعني OpenVPN على جهاز ما ويعني شيئًا لا علاقة له به على جهاز آخر. وأمر ping هو الدليل نفسه: فهو يثبت أن وجهة واحدة قد أجابت، لا أن نفقًا هو الذي حمل الحزمة. وما تكشفه المنصّة فعلًا هو مجموعة من الشبكات والوسائط التي تعلنها كل واحدة منها، وهذا سؤال مختلف بإجابة مختلفة.
خرافة: إذا وُجد tun0 فثمة VPN قائمة
تطبيقات VPN على Android هي تطبيقات VpnService تسلّم واصف ملف tun إلى النظام. واسم الواجهة يأتي ممّن أنشأها: فـ OpenVPN ينتج عادةً tun0، و PPTP و L2TP ينتجان ppp0، و WireGuard يسمّي واجهته باسم النفق، وقد لا يقدّم نفق IPsec اسمًا تقليديًا على الإطلاق. لذا فإن قراءة /proc/net/dev أو تعداد كائنات NetworkInterface يبحث عن عُرف، لا عن حقيقة.
كلا اتجاهي الخطأ قائم. فقد يملك الجهاز tun0 لنفق محلي لا علاقة له بـ VPN المستخدم، وقد يكون نفق WireGuard سليمًا تمامًا غير مرئي لفحص لا يعرف إلا السلسلة tun0. والنتائج الإيجابية الكاذبة والسلبية الكاذبة في الدالة نفسها تجعلها غير صالحة للتشخيص، لأن تذكرة دعم تقول «لم يُكتشف VPN» لا يمكن الوثوق بها في أي اتجاه.
وقد ضاق الوصول أيضًا. فإصدارات Android الأحدث تقيّد ما يمكن لتطبيق عادي قراءته من /proc، لذا قد يُرجع فحص قائم على الاسم عمل على Android 8 قائمة فارغة على جهاز لاحق دون أي تغيير في الشيفرة. وعندما يفشل الفحص بصمت على نحو مفتوح، يستنتج التطبيق أنه لا يوجد VPN على عتاد النفق فيه سليم.
خرافة: نجاح ping يعني أن النفق يحمل حركة المرور
ينجح ping إلى مضيف داخلي كلما وُجد مسار إلى ذلك المضيف. فمع النفق المنقسم قد يكون المضيف متاحًا عبر Wi-Fi بينما النفق قائم ويفعل شيئًا آخر تمامًا. ومع VPN لكل تطبيق قد يكون التطبيق الذي ينفّذ ping مستبعدًا من النفق، فتخرج رسالة ICMP الصدى خارجه وتعود، وهذا يثبت عكس ما يفترضه الفحص.
كما تُفلتر ICMP و TCP كلٌّ على حدة. فالنفق الذي يسمح بطلبات الصدى ويسقط منفذ TCP الذي تحتاجه خدمة ما سينجح في اختبار ping ويفشل في التطبيق، وذلك قاعدة جدار حماية على الطرف الآخر لا خاصية في الجهاز. والتفسير الصادق الوحيد لنجاح ping هو أن هذه الوجهة أجابت هذا البروتوكول في هذه اللحظة.
ما تُبلّغ به المنصّة فعلًا
منذ API 21 تُرجع ConnectivityManager.getAllNetworks() كل شبكة يعرفها النظام، ويبلّغ getNetworkCapabilities(network) عمّا تعلنه تلك الشبكة. وشبكة VPN تحمل TRANSPORT_VPN. والمرور على كل الشبكات يجيب عن سؤال واحد: هل توجد أي VPN قائمة الآن على هذا الجهاز؟
السؤال الثاني الأضيق يحتاج إلى نداء مختلف. تُرجع getActiveNetwork() الشبكة التي ستستخدمها حركة التطبيق الآن، وفحص القدرة نفسها عليها يجيب: هل ستمرّ حركة المرور الخاصة بي عبر VPN؟ وقد تختلف الاثنتان، والاختلاف مفيد. فـ VPN قائمة لكنها غير نشطة تشير إلى توجيه لكل تطبيق أو إلى إعداد نفق منقسم، لا إلى نفق مفقود.
Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean viaVpn = caps != null
&& caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
احترس في النداءين. فالقدرات قد تكون null لشبكة اختفت بين السرد والاستعلام، وقد تكون الشبكة النشطة null أثناء تسليم الاتصال. و NET_CAPABILITY_NOT_VPN مفيدة في الاتجاه الآخر، لتأكيد أن شبكة ليست نفقًا بالتأكيد بدلًا من مجرد افتقارها إلى علم نقل VPN.
كان NetworkInfo الخرافة نفسها مع API
ConnectivityManager.TYPE_VPN مع NetworkInfo هو الجيل الثاني من هذا الفحص، وهو مهجور لسبب. ففي الإصدارات الحديثة قد يكون الكائن المُرجَع لـ VPN قيمته null بينما النفق متصل، لذا فالشيفرة التي تعامل null على أنه «لا VPN» تبلّغ بصمت عن نتيجة سلبية كاذبة على الأجهزة التي تكون الإجابة فيها أهم ما يكون.
ولا يفضُل عليه البثّ الذي يرافقه عادةً. فلـ CONNECTIVITY_CHANGE تاريخ موثّق من تفويت انتقالات VPN، بما في ذلك إصدارات أطلقته فقط عند اتصال VPN أو فصله بدلًا من كل تغيّر في الشبكة. والمستمع المبني عليه ينتج متغيّر حالة ينحرف عن رؤية المنصّة حتى إعادة الاتصال الكاملة التالية.
تفاعل مع الاستدعاءات بدلًا من الاستقصاء الدوري
سجّل NetworkRequest يطلب TRANSPORT_VPN ويزيل NET_CAPABILITY_NOT_VPN، ثم استدعِ registerNetworkCallback بذلك الطلب. عندها يخبرك onAvailable و onLost عندما يتغيّر الإعلان، وهو الحدث الذي يهمّك فعلًا. وألغِ تسجيل الاستدعاء عندما يتوقف المكوّن، وإلا احتفظ التطبيق بمرجع إلى شاشة ميتة وظلّ الاستدعاء يُطلق.
NetworkRequest req = new NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build();
cm.registerNetworkCallback(req, callback);
يتطلب ذلك ACCESS_NETWORK_STATE، وهي إذن عادي: لا root، ولا مربّع حوار موافقة المستخدم، ولا إذن VPN. والاستقصاء الدوري بمؤقّت هو البديل الذي يلجأ إليه الناس، وهو يستهلك البطارية بينما يفوّت مع ذلك انتقالًا أقصر من الفاصل، وهو بالضبط الانتقال الذي يبلّغ عنه المستخدم.
ما لا تستطيع الإجابة إخبارك به بعد
لا يبلّغ أي من هذه الواجهات عن سلامة النفق، ولا عن الخادم الذي ينتهي عنده، ولا عمّا إذا كانت حركة تطبيق معيّن داخله. فقد توجد شبكة VPN دون مسارات صالحة، وقد يستبعد نفق لكل تطبيق التطبيق الذي يطرح السؤال.
لذا سجّل ما رآه الفحص، لا القيمة المنطقية وحدها. سجّل معرّف الشبكة والوسائط المعلنة وما إذا كانت تلك الشبكة نشطة. فتقرير يقول «لم يُكتشف VPN» غير قابل للاستخدام، بينما تقرير يقول إن شبكة أعلنت TRANSPORT_VPN لكن getActiveNetwork() أعادت Wi-Fi يشير مباشرةً إلى التوجيه أو إلى قائمة استبعاد، وذلك إصلاح يمكن لشخص أن يعمل عليه.