addDnsServer وعنوان غير صالح: ما الذي يتحقق منه Android فعلاً
خطأ العنوان غير الصالح من addDnsServer هو فحص تحليل محلي، وليس انقطاعًا في DNS. حلّل العنوان الحرفي، واستبعد loopback والبدائل الشاملة، ثم اختبر إمكانية الوصول.
وصلت القيمة إلى VpnService.Builder.addDnsServer كنص، ورفضها المُنشئ هناك قبل أن يُنشأ أي شيء. هذا الرفض محلي: لم يُتصل بأي مُحلِّل، ولا يوجد نفق، وما فشل هو فحص تنسيق لا انقطاع DNS. قراءة الأثر كمشكلة تعارض في إدارة الطاقة أو كمشكلة على الخادم تدفعك إلى الطبقة الخاطئة. فالإطار يسمّي الوسيط المرفوض، لا الإعداد الذي أنتجه.
وهذا مهم لأن تطبيق VPN النموذجي يضم ثلاثة مدخلات DNS متشابهة: قيمة تُكتب أو تُستورد في الملف الشخصي، وقيمة يدفعها الخادم أثناء الضبط، وPrivate DNS مضبوط على مستوى النظام. الأولان فقط يمكن أن يصلا إلى هذا الاستدعاء، وواحد منهما فقط هو عادة مصدر السلسلة السيئة.
كائن تجهيز يفحص الوسيط في الحال
VpnService.Builder ليس النفق. إنه كائن تجهيز يجمع العناوين والمسارات والخوادم، ولا establish() يحوّل تلك القوائم إلى ParcelFileDescriptor حي وإلى واجهة. ويُفحص إدخال DNS عند استدعاء addDnsServer، لا عند establish، ولهذا يُطرح الاستثناء قبل عدة أسطر من الشيفرة التي كنت تصحّحها فعلاً.
ينتج عن ذلك نتيجتان. أثر المكدس قصير وغير مفيد عن قصد: وصلت القيمة كسلسلة نصية عادية، ولا يمكن للإطار أن يعرف هل جاءت من استيراد QR أم من حِمل خادم أم من تجاوز في الإعدادات. ولأن كل ما عدا ذلك في المُنشئ ينجو من الاستدعاء الفاشل، فإن كتابة catch متسرّعة حول البناء كله تتركك بنفق له عناوين ومسارات لكن بلا أي مُحلِّل.
يحتفظ المُنشئ أيضًا بأي قائمة تعطيه إياها. وإضافة خادم يجتاز الفحص لا تثبت إلا أن السلسلة حُلّلت كعنوان حرفي وقت البناء، وحالة البديل الشامل أدناه تُظهر أنها لا تثبت حتى أن القيمة قابلة للاستخدام.
ما الذي يُسمح بأن يكون عليه الوسيط
الوسيط عنوان، لا نقطة نهاية. يقبل مرجع Builder في Android عنوانًا حرفيًا IPv4 أو IPv6، والتحليل الذي يجريه رقمي فقط: android.net.InetAddresses.parseNumericAddress هو الوجه العام لذلك المحلِّل من API 29، واستدعاء الإطار نفسه يهبط على الروتين ذاته. اسم المضيف هنا ليس عنوانًا بطيئًا؛ إنه سلسلة لا يمكن تحليلها، وفشل التحليل ذلك هو ما يظهر كاستثناء عنوان غير صالح.
يبدو التطابق العملي كما يلي:
| Input | Result | Reason |
|---|---|---|
10.8.0.1 | accepted | IPv4 literal |
2606:4700:4700::1111 | accepted | IPv6 literal |
dns.example.com | rejected | hostname; numeric parsing does not resolve it |
10.8.0.1:53 | rejected | port belongs to your own config model |
https://dns.example.com/dns-query | rejected | DoH URL, a different configuration path |
127.0.0.1 | rejected | loopback is not a remote resolver |
0.0.0.0 or :: | accepted by the builder | parses fine, but names no destination; only your own validator rejects it |
الإصدار يهم في كيفية التحقق، لا في ما يقبله المُنشئ. InetAddresses متاحة من API 29، لذا في Android 10 وما بعده يكون تحليل العنوان الحرفي استدعاءً واحدًا. كما تهم النسخة المحمَّلة: addDnsServer(InetAddress) هي الصورة الأصلية، بينما نسخة String المحمَّلة هي الإضافة الأحدث، لذا لن تُترجم عيّنة سلسلة ملصقة مقابل SDK أقدم. وتحت API 29 فإن البديل الواضح، InetAddress.getByName، سيحلّ اسم مضيف بكل سرور عبر بحث شبكي حاجب، وهو ما يحوّل خطأ مطبعيًا إلى إقلاع بطيء بدل خطأ واضح. إن كنت لا تزال تنشر لتلك الإصدارات، فأجرِ اختبارًا حرفيًا صريحًا قبل أن تمسّ الـ API الأقدم.
loopback مرفوض هنا، والبديل الشامل ليس كذلك
كثيرًا ما يوجّه عملاء VPN المكتبيون مُحلِّلهم المحلي إلى 127.0.0.1:53 ويتركون عميل النفق يمرّر الاستعلامات. ونسخ هذا الترتيب إلى ملف تعريف Android يستورد قيمة تصف الجهاز المحلي، ولا يوجد جهاز محلي لتسليمها إليه هنا: يجب أن يكون مُحلِّل النفق قابلًا للوصول على الجانب الآخر من الواجهة. هذه هي الحالة الوحيدة التي يرفضها المُنشئ نفسه: عنوان فارغ أو loopback يطرح IllegalArgumentException، كما تنص اتفاقية المرجع.
البديل الشامل هو الحالة المعاكسة، وهنا تخطئ معظم قراءات هذا الاستثناء. 0.0.0.0 و :: ليسا loopback، لذا يقبلهما المُنشئ ويضيفهما كأي عنوان حرفي آخر؛ لا شيء يُطرح وقت البناء. ثم تنهض الواجهة بمدخل مُحلِّل لا يسمّي أي وجهة، وينتقل الفشل من استثناء إلى استعلامات تذهب بهدوء إلى لا مكان. والتقاط ذلك مهمة المدقّق عندك، لا مهمة الإطار: عامِل isAnyLocalAddress كمدخل غير صالح في الدالة المساعدة أدناه وأظهره كخطأ على مستوى الحقل.
حلّل مرة واحدة عند حدود الإعداد
الإصلاح الرخيص هو التحقق حيث تدخل السلسلة إلى نموذجك وتمرير الكائن المحلَّل إلى الأمام، حتى تكون القيمة التي يراها المُنشئ هي القيمة نفسها التي تحققت منها:
import android.net.InetAddresses
import java.net.InetAddress
private fun dnsLiteralOrNull(raw: String?): InetAddress? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val address = try {
InetAddresses.parseNumericAddress(text)
} catch (e: IllegalArgumentException) {
return null
}
return address.takeUnless { it.isAnyLocalAddress || it.isLoopbackAddress }
}
تتحول النتيجة الفارغة عندئذ إلى خطأ على مستوى الحقل يحمل النص المخالف، وهو الشكل الوحيد الذي يمكن لمحادثة دعم أن تتصرف بناءً عليه. وفي مسار النجاح يذهب الكائن المُعاد مباشرة إلى builder.addDnsServer(address)، فتكون القيمة التي تحققت منها هي القيمة التي تُشحن. التقط IllegalArgumentException تحديدًا بدل Exception: فالمعالجة الواسعة حول شيفرة الإعدادات تبتلع أيضًا أخطاء المؤشر الفارغ والحالة التي تريد رؤيتها في بناء التنقيح.
قاوم اللجوء إلى استبدال مُحلِّل عام عندما تُرفض القيمة المقصودة. فالملف الشخصي الذي يستبدل بهدوء خدمة DNS عامة يظل يتصل، لكن الأسماء الداخلية تتوقف عن الحلّ ويخرج كل استعلام عبر مُحلِّل لم يختره المستخدم. الخطأ الظاهر يكلّف ردّ دعم واحدًا؛ أما الاستبدال الصامت فيكلّف الثقة بأن النفق يحفظ الاستعلامات حيث يقول الملف الشخصي.
أكّد إمكانية الوصول بعد وجود الواجهة
قبول المُنشئ هو نقطة الفحص الأولى، لا الأخيرة. وبعد نهوض الواجهة، LinkProperties.getDnsServers() يبلّغ عما استلمته الشبكة فعلاً، وهنا يظهر عدم التطابق: قائمة فارغة، أو مدخلات أقل مما أضفت، أو عائلة عناوين لا تحملها مساراتك. adb shell dumpsys connectivity يطبع الخصائص نفسها لكل شبكة عندما تحتاج إلى النظر من خارج التطبيق.
Private DNS عنصر تحكم منفصل ولا يمر عبر هذا الوسيط. اسم مضيف مزوّد DoT ينتمي إلى إعداد النظام، أو إلى مسار المُحلِّل المشفّر الخاص بك؛ وليس قيمة تُلصق في حقل IP، وإصلاح إعداد Private DNS لن يزيل استثناءً طرحه المُنشئ.
قبل أن تغيّر محلِّل الملف الشخصي مرة أخرى، اتبع هذا الترتيب: سجّل السلسلة الخام ومصدرها، وأكّد أنها عنوان حرفي لا اسم مضيف أو URL، وأكّد أنها ليست loopback ولا بديلًا شاملًا، ومرّر الكائن المحلَّل بدل إعادة تحليل النص، وعندها فقط قارن الواجهة المنشأة بالقيمة التي أردتها.