mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 10:47:15 +00:00
Merge branch 'phase3-hard-cutover'
AmneziaWG: replace kernel-module bridge with embedded SOCKS5 relay (Phase 3, in progress)
This commit is contained in:
+10
-12
@@ -24,15 +24,12 @@
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) هو نسخة من WireGuard تضيف طبقة تمويه (حزم مهملة، حشو عشوائي، إعادة كتابة رؤوس سحرية) مصممة لهزيمة أخذ البصمات القائم على DPI للبروتوكول — نفس النفق، لكنه لا يبدو كنفق على السلك.
|
||||
|
||||
- **نيتيف، وليس Docker.** يعمل AmneziaWG كواجهة نواة حقيقية على المضيف، تُرفَع وتُخفَض عبر `awg-quick`/`awg` — نفس نهج وحدة النواة DKMS التي تمنحك واجهة `wg0` نيتيف. لا حاجة لحاوية جانبية ذات صلاحيات مرتفعة.
|
||||
- **مدمج، وليس وحدة نواة (kernel module).** يعمل AmneziaWG بالكامل داخل عملية اللوحة نفسها ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) فوق مكدّس شبكة يعمل في مساحة المستخدم) — لا حاجة لبناء DKMS، ولا تعارض مع Secure Boot، ولا حاوية جانبية ذات صلاحيات مرتفعة، ولا أي شيء يُثبَّت على المضيف إطلاقًا.
|
||||
- **بروتوكول من الدرجة الأولى.** يعيش اتصال AmneziaWG الوارد في نفس جدول `Inbound` مثل البقية، لذا يحصل مجانًا على العمليات الجماعية (bulk operations)، ونافذة رمز QR/تنزيل التكوين، وروابط الاشتراك — لا يوجد شيء جديد لتعلّمه.
|
||||
- **تمويه AmneziaWG 2.0 الكامل** — Jc/Jmin/Jmax (الحزم المهملة)، وS1–S4 (حشو الحزم)، وH1–H4 (الرؤوس السحرية)، وحزمة التوقيع I1، جميعها قابلة للتعديل لكل اتصال وارد مع زر عشوائي بنقرة واحدة، بالإضافة إلى وضع متوافق مع 1.x للعملاء القدامى.
|
||||
- **دعم IPv6 نيتيف**، مع وكيل NDP لكل عميل بحيث يحصل كل نظير على عنوان IPv6 يمكن الوصول إليه مباشرة — بدون NAT66.
|
||||
- **إعادة توجيه المنافذ لكل عميل** — DNAT لمنافذ/نطاقات محددة مباشرة إلى عنوان نفق نظير واحد.
|
||||
- **توجيه ترافيك عميل عبر Xray** — يحصل كل اتصال AmneziaWG الوارد تلقائيًا على جسر Xray الخاص به عبر loopback (بدون أي مفتاح تبديل)؛ يتم توجيه ترافيك أي عميل إلى أي اتصال صادر مُهيّأ في Xray من خلال صفحة "التوجيه" الموجودة مسبقًا في اللوحة، تمامًا كما تُوجَّه أي بروتوكول آخر.
|
||||
- **يقوم `install.sh` بتثبيت وحدة النواة نيابةً عنك** على Ubuntu/Debian/Armbian (`ppa:amnezia/ppa`)، مع بديل احتياطي للتوزيعات الأخرى. الشيء الوحيد الذي لا يمكنه فعله من أجلك: **تعطيل Secure Boot** على خادمك الافتراضي (VPS/VM) مسبقًا — وحدة نواة مبنية بواسطة DKMS غير موقّعة، ولن تحمّلها النواة طالما كان Secure Boot مفعّلاً.
|
||||
- تتم المطابقة (reconcile) تمامًا كما تدير [`internal/mtproto`](internal/mtproto) الحاوية الجانبية `mtg`: تُبقي مهمة خلفية الواجهة قيد التشغيل متزامنة مع ما هو مخزَّن في قاعدة البيانات، وتطبّق تغييرات النظير عبر `awg syncconf` بدلاً من إعادة تشغيل الواجهة بالكامل كلما أمكن ذلك.
|
||||
- **روابط مشاركة `vpn://` حقيقية** — أصبح رابط النسخ/رمز QR الخاص بكل عميل ونقطة نهاية الاشتراك يُصدران الآن مخطط `vpn://` الفعلي الذي يتوقعه تطبيق AmneziaVPN الرسمي (base64url لملف `.conf` عادي)، بدلاً من تنسيق URI مُخترَع لم يكن التطبيق قادرًا على استيراده.
|
||||
- **ترافيك كل عميل يمر بالفعل عبر Xray.** لا TPROXY، ولا جسر يحتاج إلى تفعيل منفصل: كل اتصال AmneziaWG وارد يُوجَّه مباشرة إلى اتصال Xray SOCKS5 وارد خاص به عبر loopback، لذا إحصاءات كل عميل، وحالة الاتصال، والـ sniffing، وقواعد صفحة "التوجيه" الموجودة مسبقًا في اللوحة، كل ذلك يعمل تلقائيًا تمامًا كما هو الحال مع أي بروتوكول آخر — بلا أي إعداد إضافي.
|
||||
- **روابط مشاركة `vpn://` حقيقية** — رابط النسخ/رمز QR الخاص بكل عميل ونقطة نهاية الاشتراك يُصدران مخطط `vpn://` الفعلي الذي يتوقعه تطبيق AmneziaVPN الرسمي (base64url لملف `.conf` عادي)، بدلاً من تنسيق URI مُخترَع لم يكن التطبيق قادرًا على استيراده.
|
||||
- **غير مدعوم مؤقتًا** بعد هذا التحول: عنوان IPv6 عام مستقل لكل عميل، وإعادة توجيه المنافذ لكل عميل، كلاهما كان يعتمد على قواعد iptables على مستوى المضيف الخاصة بوحدة النواة القديمة، والتي لا يوجد لها بعد ما يعادلها في البنية المدمجة. كلاهما مخطط له كإصدارين لاحقين قريبًا؛ الإعدادات الحالية لأي منهما لم تُفقد، بل أصبحت فقط غير فعّالة حتى ذلك الحين.
|
||||
|
||||
## تغييرات أخرى في هذه النسخة
|
||||
|
||||
@@ -121,11 +118,11 @@ curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bas
|
||||
|
||||
## المنصات المدعومة
|
||||
|
||||
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap) و Alpine. (ينشر المشروع الأصلي أيضًا إصدارًا لـ Windows؛ لا تفعل CI هذه النسخة ذلك — كل شيء هنا موجَّه للخوادم/أجهزة التوجيه العاملة بلينكس، ويحتاج AmneziaWG على أي حال إلى وحدة نواة لينكس.)
|
||||
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap) و Alpine. (ينشر المشروع الأصلي أيضًا إصدارًا لـ Windows؛ لا تفعل CI هذه النسخة ذلك — كل شيء هنا موجَّه للخوادم/أجهزة التوجيه العاملة بلينكس.)
|
||||
|
||||
**المعماريات:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
يحتاج AmneziaWG تحديدًا إلى نواة لينكس حقيقية مع وحدة نواة DKMS خاصة بـ AmneziaWG — لن يعمل على Windows، ويقوم `install_amneziawg` اليوم بأتمتة تثبيت وحدة النواة فقط على Ubuntu/Debian/Armbian (راجع قسم [ما الذي يختلف في هذه النسخة](#ما-الذي-يختلف-في-هذه-النسخة-amneziawg)).
|
||||
AmneziaWG مدمج داخل ملف اللوحة التنفيذي نفسه (راجع قسم [ما الذي يختلف في هذه النسخة](#ما-الذي-يختلف-في-هذه-النسخة-amneziawg)) — لا وحدة نواة، ولا خطوة تثبيت منفصلة، ولا إعداد خاص بكل توزيعة.
|
||||
|
||||
## خيارات قاعدة البيانات
|
||||
|
||||
@@ -195,10 +192,11 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
استندت عملية تنفيذ AmneziaWG النيتيف في هذه النسخة إلى/استُلهمت من:
|
||||
استندت عملية تنفيذ AmneziaWG في هذه النسخة إلى/استُلهمت من:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — طلب السحب الأصلي لدعم AmneziaWG في المشروع الأصلي (نهج حاوية Docker الجانبية)؛ تعيد هذه النسخة استخدام بنية المخطط (schema) والواجهة الأمامية الخاصة به، لكنها تستبدل الواجهة الخلفية بمدير نيتيف بدون Docker.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — نسخة محسّنة مستقلة تُشغّل بالفعل AmneziaWG النيتيف في بيئة إنتاجية؛ استُمدت إدارة عملية `awg-quick`، وتوليد التكوين، ومولّد معاملات تمويه AmneziaWG 2.0 في هذه النسخة من حزمة `awg/` الخاصة بها.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — تنفيذ AmneziaWG في مساحة المستخدم الذي تُدمجه هذه النسخة مباشرةً داخل عملية اللوحة، فوق مكدّس شبكة [gVisor](https://gvisor.dev/)، ليحل محل الواجهة الخلفية القديمة القائمة على وحدة النواة أدناه.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — طلب السحب الأصلي لدعم AmneziaWG في المشروع الأصلي (نهج حاوية Docker الجانبية)؛ تعيد هذه النسخة استخدام بنية المخطط (schema) والواجهة الأمامية الخاصة به.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — نسخة محسّنة مستقلة تُشغّل بالفعل AmneziaWG النيتيف في بيئة إنتاجية؛ المدير الأصلي لهذه النسخة القائم على وحدة النواة (`awg-quick`) ومولّد معاملات تمويه AmneziaWG 2.0 استُمدّا من حزمة `awg/` الخاصة بها قبل هذا التحول.
|
||||
|
||||
## شكر خاص إلى
|
||||
|
||||
|
||||
+10
-12
@@ -24,15 +24,12 @@ Este fork se construyó para funcionar en los routers y servidores personales de
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) es una variante de WireGuard que añade una capa de ofuscación (paquetes basura, relleno aleatorio, reescritura de cabeceras mágicas) diseñada para vencer la identificación de huella digital de protocolo basada en DPI — el mismo túnel, pero uno que ya no parece un túnel en el cable.
|
||||
|
||||
- **Nativo, no Docker.** AmneziaWG se ejecuta como una interfaz de kernel real en el host, activada y desactivada mediante `awg-quick`/`awg` — el mismo enfoque de módulo de kernel DKMS que te da una interfaz `wg0` nativa. No se necesita ningún contenedor sidecar con privilegios.
|
||||
- **Integrado, no un módulo de kernel.** AmneziaWG se ejecuta enteramente dentro del propio proceso del panel ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) sobre una pila de red en espacio de usuario) — sin compilación DKMS, sin conflictos con Secure Boot, sin contenedor sidecar con privilegios, y nada que instalar en el host en absoluto.
|
||||
- **Un protocolo de primera clase.** Una entrada AmneziaWG vive en la misma tabla `Inbound` que el resto, por lo que obtiene gratuitamente las operaciones masivas, el modal de código QR/descarga de configuración y los enlaces de suscripción — nada nuevo que aprender.
|
||||
- **Ofuscación completa de AmneziaWG 2.0** — Jc/Jmin/Jmax (paquetes basura), S1–S4 (relleno de paquetes), H1–H4 (cabeceras mágicas) y el paquete de firma I1, todos editables por entrada con un botón de aleatorización de un solo clic, además de un modo compatible con 1.x para clientes más antiguos.
|
||||
- **IPv6 nativo**, con proxy NDP por cliente para que cada par obtenga una dirección IPv6 directamente accesible — sin NAT66.
|
||||
- **Reenvío de puertos por cliente** — DNAT de puertos/rangos específicos directamente a la dirección de túnel de un par.
|
||||
- **Enrutamiento del tráfico de un cliente a través de Xray** — cada entrada AmneziaWG obtiene automáticamente su propio puente Xray por loopback (sin ningún interruptor); enruta el tráfico de cualquier cliente hacia cualquier salida de Xray configurada desde la página de "Enrutamiento" ya existente en el panel, exactamente igual que al enrutar cualquier otro protocolo.
|
||||
- **`install.sh` instala el módulo de kernel por ti** en Ubuntu/Debian/Armbian (`ppa:amnezia/ppa`), con una alternativa de respaldo para otras distribuciones. Lo único que no puede hacer por ti: **deshabilitar Secure Boot** en tu VPS/VM de antemano — un módulo compilado con DKMS no está firmado, y el kernel se negará a cargarlo mientras Secure Boot esté habilitado.
|
||||
- La reconciliación se realiza exactamente igual que como [`internal/mtproto`](internal/mtproto) gestiona el sidecar `mtg`: un trabajo en segundo plano mantiene la interfaz en ejecución sincronizada con lo almacenado en la base de datos, aplicando los cambios de pares mediante `awg syncconf` en lugar de un reinicio completo de la interfaz siempre que sea posible.
|
||||
- **Enlaces de compartición `vpn://` reales** — el enlace de copia/código QR por cliente y el endpoint de suscripción ahora emiten el esquema `vpn://` real que espera la aplicación oficial AmneziaVPN (base64url de un `.conf` plano), no un formato de URI inventado que la app no podía importar.
|
||||
- **El tráfico de cada cliente ya pasa por Xray.** Sin TPROXY, sin un puente que activar aparte: cada entrada AmneziaWG reenvía directamente al propio inbound SOCKS5 de Xray por loopback, por lo que las estadísticas por cliente, el estado en línea, el sniffing y las reglas de la página "Enrutamiento" ya existente en el panel simplemente funcionan, igual que con cualquier otro protocolo — sin configuración adicional.
|
||||
- **Enlaces de compartición `vpn://` reales** — el enlace de copia/código QR por cliente y el endpoint de suscripción emiten el esquema `vpn://` real que espera la aplicación oficial AmneziaVPN (base64url de un `.conf` plano), no un formato de URI inventado que la app no podía importar.
|
||||
- **Temporalmente sin soporte** tras esta reescritura: una dirección IPv6 pública distinta por cliente y el reenvío de puertos por cliente dependían ambos de las reglas de iptables a nivel de host del antiguo módulo de kernel, que aún no tienen un equivalente integrado. Ambos están planificados como próximas versiones; la configuración ya guardada de cualquiera de los dos no se pierde, simplemente queda inactiva hasta entonces.
|
||||
|
||||
## Otros cambios en este fork
|
||||
|
||||
@@ -121,11 +118,11 @@ ninguna pregunta, generando credenciales aleatorias y escribiéndolas en
|
||||
|
||||
## Plataformas Compatibles
|
||||
|
||||
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) y Alpine. (El proyecto original también publica una versión para Windows; la CI de este fork no lo hace — todo aquí está orientado a servidores/routers que ejecutan Linux, y AmneziaWG necesita un módulo de kernel de Linux de todos modos.)
|
||||
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) y Alpine. (El proyecto original también publica una versión para Windows; la CI de este fork no lo hace — todo aquí está orientado a servidores/routers que ejecutan Linux.)
|
||||
|
||||
**Arquitecturas:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
AmneziaWG requiere específicamente un kernel de Linux real con el módulo de kernel DKMS de AmneziaWG — no funcionará en Windows, y `install_amneziawg` hoy solo automatiza la instalación del módulo de kernel en Ubuntu/Debian/Armbian (consulta la sección [En qué se diferencia este fork](#en-qué-se-diferencia-este-fork-amneziawg)).
|
||||
AmneziaWG está integrado directamente en el propio binario del panel (consulta la sección [En qué se diferencia este fork](#en-qué-se-diferencia-este-fork-amneziawg)) — sin módulo de kernel, sin paso de instalación aparte, sin configuración específica por distribución.
|
||||
|
||||
## Opciones de Base de Datos
|
||||
|
||||
@@ -195,10 +192,11 @@ Este fork está construido enteramente sobre [MHSanaei/3x-ui](https://github.com
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
La implementación nativa de AmneziaWG en este fork se basa en / está inspirada por:
|
||||
La implementación de AmneziaWG en este fork se basa en / está inspirada por:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — el pull request original de AmneziaWG contra el proyecto original (enfoque de sidecar Docker); este fork reutiliza su estructura de schema/frontend pero reemplaza el backend por un gestor nativo sin Docker.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — un fork independiente que ya ejecuta AmneziaWG nativo en producción; la gestión del proceso `awg-quick`, la generación de configuración y el generador de parámetros de ofuscación de AmneziaWG 2.0 de este fork provienen de su paquete `awg/`.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — la implementación de AmneziaWG en espacio de usuario que este fork integra directamente en el proceso del panel, sobre una pila de red [gVisor](https://gvisor.dev/), sustituyendo al backend original basado en módulo de kernel de abajo.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — el pull request original de AmneziaWG contra el proyecto original (enfoque de sidecar Docker); este fork reutiliza su estructura de schema/frontend.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — un fork independiente que ya ejecuta AmneziaWG nativo en producción; el gestor original de este fork basado en el módulo de kernel (`awg-quick`) y el generador de parámetros de ofuscación de AmneziaWG 2.0 se portaron desde su paquete `awg/` antes de esta reescritura.
|
||||
|
||||
## Un Agradecimiento Especial a
|
||||
|
||||
|
||||
+10
-12
@@ -24,15 +24,12 @@
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) نسخهای از WireGuard است با یک لایهی مبهمسازی اضافه (بستههای زباله، پدینگ تصادفی، بازنویسی سرآیندهای جادویی) که برای شکست دادن اثرانگشتگیری پروتکل مبتنی بر DPI طراحی شده — همان تونل، اما تونلی که روی سیم شبیه تونل به نظر نمیرسد.
|
||||
|
||||
- **نیتیو، نه Docker.** AmneziaWG بهعنوان یک اینترفیس واقعی کرنل روی هاست اجرا میشود، با `awg-quick`/`awg` بالا و پایین میآید — همان رویکرد ماژول کرنل DKMS که یک اینترفیس نیتیو `wg0` دارد. هیچ کانتینر سایدکار ممتازی لازم نیست.
|
||||
- **جاسازیشده (embedded)، نه یک ماژول کرنل.** AmneziaWG کاملاً درون خود پروسهی پنل اجرا میشود ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) روی یک پشتهی شبکه در فضای کاربر) — بدون نیاز به بیلد DKMS، بدون تداخل با Secure Boot، بدون کانتینر سایدکار ممتاز، و اصلاً چیزی برای نصب روی هاست وجود ندارد.
|
||||
- **یک پروتکل درجهیک.** یک اینباند AmneziaWG در همان جدول `Inbound` بقیهی موارد زندگی میکند، پس bulk-operations، مودال QR/دانلود کانفیگ و لینکهای سابسکریپشن را رایگان دریافت میکند — چیز جدیدی برای یادگیری نیست.
|
||||
- **مبهمسازی کامل AmneziaWG 2.0** — Jc/Jmin/Jmax (بستههای زباله)، S1–S4 (پدینگ بسته)، H1–H4 (سرآیندهای جادویی) و بستهی امضای I1، همه بهازای هر اینباند با یک دکمهی تصادفیسازی یککلیکی قابل ویرایشاند، بهعلاوهی یک حالت سازگار با 1.x برای کلاینتهای قدیمیتر.
|
||||
- **IPv6 نیتیو**، با پراکسی NDP بهازای هر کلاینت تا هر پیر یک آدرس IPv6 مستقیماً در دسترس داشته باشد — بدون NAT66.
|
||||
- **پروبرت پورت بهازای هر کلاینت** — DNAT کردن پورتها/محدودههای مشخص مستقیماً به آدرس تونل یک پیر.
|
||||
- **مسیریابی ترافیک یک کلاینت از طریق Xray** — هر اینباند AmneziaWG بهطور خودکار پل loopback مخصوص به خودش را در Xray دریافت میکند (بدون هیچ سوئیچی)؛ مسیریابی ترافیک هر کلاینت به هر outbound پیکربندیشده در Xray از طریق صفحهی «مسیریابی» موجود در پنل انجام میشود، دقیقاً مثل مسیریابی هر پروتکل دیگر.
|
||||
- **`install.sh` ماژول کرنل را برایتان نصب میکند** روی Ubuntu/Debian/Armbian (`ppa:amnezia/ppa`)، با یک fallback برای سایر توزیعها. یک کار که نمیتواند برایتان انجام دهد: **غیرفعال کردن Secure Boot** روی VPS/VMتان از قبل — یک ماژول ساختهشده با DKMS امضا نشده است و کرنل تا زمانی که Secure Boot فعال باشد از بارگذاری آن خودداری میکند.
|
||||
- تطبیق (reconcile) دقیقاً مثل نحوهی مدیریت سایدکار `mtg` توسط [`internal/mtproto`](internal/mtproto) انجام میشود: یک جاب پسزمینه اینترفیس در حال اجرا را با آنچه در پایگاهداده ذخیره شده هماهنگ نگه میدارد، و تغییرات پیر را از طریق `awg syncconf` بهجای ریاستارت کامل اینترفیس، در جایی که ممکن باشد، اعمال میکند.
|
||||
- **لینکهای اشتراکگذاری واقعی `vpn://`** — لینک کپی/QR هر کلاینت و endpoint سابسکریپشن اکنون همان طرح واقعی `vpn://` را که اپ رسمی AmneziaVPN انتظار دارد تولید میکنند (base64url یک فایل `.conf` ساده)، نه یک فرمت URI ساختگی که آن اپ نمیتوانست وارد کند.
|
||||
- **ترافیک هر کلاینت همین حالا از Xray عبور میکند.** بدون TPROXY، بدون پلی که لازم باشد جداگانه فعال شود: هر اینباند AmneziaWG مستقیماً به اینباند SOCKS5 مخصوص به خودش در Xray از طریق loopback ریلی میشود، پس آمار هر کلاینت، وضعیت آنلاین، sniffing و قوانین صفحهی «مسیریابی» موجود در پنل، همگی دقیقاً مثل هر پروتکل دیگری بهطور خودکار کار میکنند — بدون هیچ پیکربندی اضافهای.
|
||||
- **لینکهای اشتراکگذاری واقعی `vpn://`** — لینک کپی/QR هر کلاینت و endpoint سابسکریپشن همان طرح واقعی `vpn://` را که اپ رسمی AmneziaVPN انتظار دارد تولید میکنند (base64url یک فایل `.conf` ساده)، نه یک فرمت URI ساختگی که آن اپ نمیتوانست وارد کند.
|
||||
- **موقتاً پشتیبانی نمیشود** پس از این بازنویسی: یک آدرس IPv6 عمومی مجزا بهازای هر کلاینت و پروبرت پورت بهازای هر کلاینت، هر دو به قوانین iptables در سطح هاست ماژول کرنل قدیمی متکی بودند که هنوز معادلی در معماری جاسازیشده ندارند. هر دو بهعنوان نسخههای بعدی و نزدیک برنامهریزی شدهاند؛ تنظیمات ذخیرهشدهی موجود برای هرکدام از بین نرفته، فقط تا آن زمان غیرفعال است.
|
||||
|
||||
## سایر تغییرات این فورک
|
||||
|
||||
@@ -121,11 +118,11 @@ curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bas
|
||||
|
||||
## پلتفرمهای پشتیبانیشده
|
||||
|
||||
**سیستمعاملها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap) و Alpine. (پروژهی اصلی یک نسخهی Windows نیز منتشر میکند؛ CI این فورک این کار را نمیکند — همهچیز اینجا سرورها/روترهای لینوکسی را هدف قرار میدهد، و AmneziaWG در هر صورت به یک ماژول کرنل لینوکس نیاز دارد.)
|
||||
**سیستمعاملها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap) و Alpine. (پروژهی اصلی یک نسخهی Windows نیز منتشر میکند؛ CI این فورک این کار را نمیکند — همهچیز اینجا سرورها/روترهای لینوکسی را هدف قرار میدهد.)
|
||||
|
||||
**معماریها:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
AmneziaWG بهطور خاص به یک کرنل واقعی لینوکس با ماژول DKMS مربوط به AmneziaWG نیاز دارد — روی Windows بالا نخواهد آمد، و `install_amneziawg` امروز فقط نصب ماژول کرنل را روی Ubuntu/Debian/Armbian خودکار میکند (به بخش [تفاوت این فورک](#تفاوت-این-فورک-amneziawg) مراجعه کنید).
|
||||
AmneziaWG مستقیماً درون خود باینری پنل جاسازی شده است (به بخش [تفاوت این فورک](#تفاوت-این-فورک-amneziawg) مراجعه کنید) — بدون ماژول کرنل، بدون مرحلهی نصب جداگانه، بدون تنظیمات مخصوص هر توزیع.
|
||||
|
||||
## گزینههای پایگاهداده
|
||||
|
||||
@@ -195,10 +192,11 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
پیادهسازی نیتیو AmneziaWG در این فورک برگرفته از/الهامگرفته از موارد زیر است:
|
||||
پیادهسازی AmneziaWG در این فورک برگرفته از/الهامگرفته از موارد زیر است:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — پولریکوئست اصلی AmneziaWG علیه پروژهی اصلی (رویکرد Docker-sidecar)؛ این فورک ساختار schema/UI فرانتاند آن را دوباره استفاده میکند اما بکاند را با یک مدیر نیتیو و بدون Docker جایگزین میکند.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — یک فورک مستقل که از قبل AmneziaWG نیتیو را در محیط تولید اجرا میکند؛ مدیریت فرآیند `awg-quick`، تولید کانفیگ و تولیدکنندهی پارامتر مبهمسازی AmneziaWG 2.0 این فورک از پکیج `awg/` آن برگرفته شده است.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — پیادهسازی AmneziaWG در فضای کاربر که این فورک مستقیماً درون پروسهی پنل، روی پشتهی شبکهی [gVisor](https://gvisor.dev/)، جاسازی میکند و جایگزین بکاند قدیمی مبتنی بر ماژول کرنل در زیر میشود.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — پولریکوئست اصلی AmneziaWG علیه پروژهی اصلی (رویکرد Docker-sidecar)؛ این فورک ساختار schema/UI فرانتاند آن را دوباره استفاده میکند.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — یک فورک مستقل که از قبل AmneziaWG نیتیو را در محیط تولید اجرا میکند؛ مدیر قدیمی این فورک مبتنی بر ماژول کرنل (`awg-quick`) و تولیدکنندهی پارامتر مبهمسازی AmneziaWG 2.0 پیش از این بازنویسی از پکیج `awg/` آن برگرفته شده بودند.
|
||||
|
||||
## تشکر ویژه از
|
||||
|
||||
|
||||
@@ -24,15 +24,12 @@ This fork exists to run the author's own routers and servers; it isn't trying to
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) is WireGuard with an added obfuscation layer (junk packets, randomized padding, magic-header rewriting) designed to defeat DPI-based protocol fingerprinting — the same tunnel, but one that doesn't look like a tunnel on the wire.
|
||||
|
||||
- **Native, not Docker.** AmneziaWG runs as a real kernel interface on the host, brought up and torn down with `awg-quick`/`awg` — the same DKMS kernel module approach as a native `wg0` interface. No privileged sidecar containers.
|
||||
- **Embedded, not a kernel module.** AmneziaWG runs entirely inside the panel process ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) over a userspace network stack) — no DKMS build, no Secure Boot conflict, no privileged sidecar container, and nothing to install on the host at all.
|
||||
- **A first-class protocol.** An AmneziaWG inbound lives in the same `Inbound` table as everything else, so it gets bulk operations, the QR/config-download modal, and subscription links for free — nothing bespoke to learn.
|
||||
- **Full AmneziaWG 2.0 obfuscation** — Jc/Jmin/Jmax (junk packets), S1–S4 (packet padding), H1–H4 (magic headers), and the I1 signature packet, all editable per-inbound with a one-click randomize button, plus a 1.x-compatible fallback for older clients.
|
||||
- **Native IPv6**, with per-client NDP proxying so each peer gets a directly-reachable IPv6 address — no NAT66.
|
||||
- **Per-client port-forwarding** — DNAT specific ports/ranges straight to one peer's tunnel address.
|
||||
- **Routing a client's traffic through Xray** — every AmneziaWG inbound gets its own loopback Xray bridge automatically (no toggle to flip); route any client's traffic through any configured Xray outbound from the panel's existing Routing page, exactly like routing any other protocol.
|
||||
- **`install.sh` installs the kernel module for you** on Ubuntu/Debian/Armbian (`ppa:amnezia/ppa`), with a fallback for other distros. One thing it can't do for you: **disable Secure Boot** on your VPS/VM first — a DKMS-built module is unsigned and the kernel won't load it while Secure Boot is enforced.
|
||||
- Reconciled the same way [`internal/mtproto`](internal/mtproto) manages its `mtg` sidecar: a background job keeps the running interface in sync with what's saved in the database, hot-reloading peer changes via `awg syncconf` instead of bouncing the whole interface when it can.
|
||||
- **Real `vpn://` share links** — the per-client copy-link/QR and the subscription endpoint now emit the actual `vpn://` scheme the official AmneziaVPN app expects (base64url of a plain `.conf`), not an invented URI format it couldn't import.
|
||||
- **Every client's traffic already goes through Xray.** No TPROXY, no bridge to opt into: each AmneziaWG inbound relays straight into its own loopback Xray SOCKS5 inbound, so per-client traffic stats, online status, sniffing, and the panel's existing Routing-page rules all just work, exactly like any other protocol — no extra configuration.
|
||||
- **Real `vpn://` share links** — the per-client copy-link/QR and the subscription endpoint emit the actual `vpn://` scheme the official AmneziaVPN app expects (base64url of a plain `.conf`), not an invented URI format it couldn't import.
|
||||
- **Temporarily unsupported** after this rewrite: distinct per-client public IPv6 addresses and per-client port-forwarding both relied on the old kernel-module's host-level iptables rules, which don't have an embedded equivalent yet. Both are planned fast-follow releases; existing settings for either aren't lost, they're just inert until then.
|
||||
|
||||
## Other changes in this fork
|
||||
|
||||
@@ -121,11 +118,11 @@ zero prompts, generating random credentials and writing them to
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), and Alpine. (Upstream also publishes a Windows build; this fork's CI doesn't — everything here targets Linux servers/routers, and AmneziaWG needs a Linux kernel module regardless.)
|
||||
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), and Alpine. (Upstream also publishes a Windows build; this fork's CI doesn't — everything here targets Linux servers/routers.)
|
||||
|
||||
**Architectures:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
AmneziaWG specifically needs a real Linux kernel with the AmneziaWG DKMS module — it will not come up on Windows, and `install_amneziawg` only automates the kernel-module install on Ubuntu/Debian/Armbian today (see [What's different in this fork](#whats-different-in-this-fork-amneziawg)).
|
||||
AmneziaWG is embedded in the panel binary itself (see [What's different in this fork](#whats-different-in-this-fork-amneziawg)) — no kernel module, no separate install step, no distro-specific setup.
|
||||
|
||||
## Database Options
|
||||
|
||||
@@ -195,10 +192,11 @@ This fork is built entirely on top of [MHSanaei/3x-ui](https://github.com/MHSana
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
The native AmneziaWG implementation in this fork was ported from/inspired by:
|
||||
The AmneziaWG implementation in this fork was ported from/inspired by:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — the original AmneziaWG PR against upstream (Docker-sidecar approach); this fork reuses its frontend schema/UI structure but replaces the backend with a native, no-Docker manager.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — an independent fork already running native AmneziaWG in production; this fork's `awg-quick` process management, config generation, and AmneziaWG 2.0 obfuscation parameter generator are ported from its `awg/` package.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — the userspace AmneziaWG implementation this fork embeds directly in the panel process, over a [gVisor](https://gvisor.dev/) network stack, replacing the original kernel-module-based backend below.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — the original AmneziaWG PR against upstream (Docker-sidecar approach); this fork reuses its frontend schema/UI structure.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — an independent fork already running native AmneziaWG in production; this fork's original kernel-module (`awg-quick`) manager and AmneziaWG 2.0 obfuscation parameter generator were ported from its `awg/` package before this rewrite.
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
|
||||
+10
-12
@@ -24,15 +24,12 @@
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) — это WireGuard с добавленным слоем обфускации (мусорные пакеты, случайный паддинг, переписывание магических заголовков), который призван обмануть DPI-фингерпринтинг протокола: тот же туннель, но не выглядящий как туннель в трафике.
|
||||
|
||||
- **Нативно, без Docker.** AmneziaWG работает как настоящий интерфейс ядра на хосте, поднимается и опускается через `awg-quick`/`awg` — тот же подход через DKMS-модуль ядра, что и у обычного интерфейса `wg0`. Никаких привилегированных сайдкар-контейнеров.
|
||||
- **Встроено, а не модуль ядра.** AmneziaWG работает целиком внутри процесса панели ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) поверх пользовательского сетевого стека) — никакой сборки DKMS, никакого конфликта с Secure Boot, никаких привилегированных сайдкар-контейнеров и вообще ничего ставить на хост не нужно.
|
||||
- **Полноценный протокол.** AmneziaWG-инбаунд живёт в той же таблице `Inbound`, что и всё остальное, поэтому бесплатно получает bulk-операции, модалку QR/скачивания конфига и ссылки подписки — учить ничего отдельного не нужно.
|
||||
- **Полная обфускация AmneziaWG 2.0** — Jc/Jmin/Jmax (мусорные пакеты), S1–S4 (паддинг пакетов), H1–H4 (магические заголовки) и сигнатурный пакет I1, всё редактируется по каждому инбаунду с кнопкой генерации одним кликом, плюс совместимый с 1.x режим для старых клиентов.
|
||||
- **Нативный IPv6** с NDP-прокси по каждому клиенту — у каждого пира напрямую доступный IPv6-адрес, без NAT66.
|
||||
- **Проброс портов по клиентам** — DNAT конкретных портов/диапазонов прямо на туннельный адрес одного пира.
|
||||
- **Маршрутизация трафика клиента через Xray** — каждый AmneziaWG-инбаунд автоматически получает свой loopback-мост в Xray (без переключателей), а куда направить трафик конкретного клиента — решается через уже существующее меню «Маршрутизация» панели, точно так же, как для любого другого протокола.
|
||||
- **`install.sh` сам ставит модуль ядра** на Ubuntu/Debian/Armbian (`ppa:amnezia/ppa`), с fallback для других дистрибутивов. Одно он сделать не может: **выключить Secure Boot** на вашем VPS/VM заранее — DKMS-модуль не подписан, и ядро откажется его загружать, пока Secure Boot включён.
|
||||
- Реконсиляция устроена так же, как [`internal/mtproto`](internal/mtproto) управляет своим сайдкаром `mtg`: фоновая джоба держит запущенный интерфейс синхронизированным с тем, что сохранено в базе, применяя изменения пиров через `awg syncconf` вместо полного рестарта интерфейса там, где это возможно.
|
||||
- **Настоящие ссылки `vpn://`** — ссылка-копия/QR у клиента и endpoint подписки теперь отдают тот самый формат `vpn://`, который ожидает официальное приложение AmneziaVPN (base64url обычного `.conf`), а не выдуманный формат URI, который оно не могло импортировать.
|
||||
- **Трафик каждого клиента уже идёт через Xray.** Никакого TPROXY, никакого моста, который нужно включать отдельно: каждый AmneziaWG-инбаунд напрямую передаёт трафик в свой loopback-инбаунд SOCKS5 в Xray, поэтому статистика по клиентам, онлайн-статус, sniffing и правила уже существующего меню «Маршрутизация» работают точно так же, как для любого другого протокола — без дополнительной настройки.
|
||||
- **Настоящие ссылки `vpn://`** — ссылка-копия/QR у клиента и endpoint подписки отдают тот самый формат `vpn://`, который ожидает официальное приложение AmneziaVPN (base64url обычного `.conf`), а не выдуманный формат URI, который оно не могло импортировать.
|
||||
- **Временно не поддерживается** после этого перехода: отдельный публичный IPv6-адрес на каждого клиента и проброс портов по клиентам — обе функции опирались на хостовые правила iptables старого модуля ядра, а у встроенной архитектуры пока нет их аналога. Обе запланированы отдельными релизами следом; уже сохранённые настройки для них никуда не делись, просто пока не действуют.
|
||||
|
||||
## Другие изменения в этом форке
|
||||
|
||||
@@ -121,11 +118,11 @@ curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bas
|
||||
|
||||
## Поддерживаемые платформы
|
||||
|
||||
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) и Alpine. (В апстриме также есть сборка под Windows; в CI этого форка её нет — здесь всё нацелено на Linux-серверы/роутеры, да и AmneziaWG в любом случае требует модуль ядра Linux.)
|
||||
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) и Alpine. (В апстриме также есть сборка под Windows; в CI этого форка её нет — здесь всё нацелено на Linux-серверы/роутеры.)
|
||||
|
||||
**Архитектуры:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
AmneziaWG отдельно требует настоящее ядро Linux с DKMS-модулем AmneziaWG — на Windows он не поднимется, а `install_amneziawg` пока автоматизирует установку модуля только на Ubuntu/Debian/Armbian (см. [Чем этот форк отличается: AmneziaWG](#чем-этот-форк-отличается-amneziawg)).
|
||||
AmneziaWG встроен прямо в бинарник панели (см. [Чем этот форк отличается: AmneziaWG](#чем-этот-форк-отличается-amneziawg)) — никакого модуля ядра, никакого отдельного шага установки, никакой специфики под конкретный дистрибутив.
|
||||
|
||||
## Варианты базы данных
|
||||
|
||||
@@ -195,10 +192,11 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
Нативная реализация AmneziaWG в этом форке портирована/вдохновлена:
|
||||
Реализация AmneziaWG в этом форке портирована/вдохновлена:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — оригинальный PR с AmneziaWG в апстрим (подход через Docker-сайдкар); этот форк переиспользует его фронтенд-схему/структуру UI, но заменяет бэкенд на нативный менеджер без Docker.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — независимый форк, уже использующий нативный AmneziaWG в проде; управление процессом `awg-quick`, генерация конфига и генератор параметров обфускации AmneziaWG 2.0 в этом форке портированы из его пакета `awg/`.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — пользовательская реализация AmneziaWG, которую этот форк встраивает прямо в процесс панели поверх сетевого стека [gVisor](https://gvisor.dev/), заменяя собой прежний бэкенд на основе модуля ядра ниже.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — оригинальный PR с AmneziaWG в апстрим (подход через Docker-сайдкар); этот форк переиспользует его фронтенд-схему/структуру UI.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — независимый форк, уже использующий нативный AmneziaWG в проде; прежний менеджер этого форка на основе модуля ядра (`awg-quick`) и генератор параметров обфускации AmneziaWG 2.0 были портированы из его пакета `awg/` до этого перехода.
|
||||
|
||||
## Благодарности
|
||||
|
||||
|
||||
+10
-12
@@ -24,15 +24,12 @@ Bu fork, yazarının kendi yönlendiricileri ve kişisel sunucuları üzerinde
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module), DPI tabanlı protokol parmak izi çıkarmayı yenmek için tasarlanmış bir gizleme (obfuscation) katmanı (çöp paketler, rastgele dolgu, sihirli başlıkların yeniden yazılması) ekleyen bir WireGuard varyantıdır — aynı tünel, ama artık hat üzerinde bir tünel gibi görünmeyen bir tünel.
|
||||
|
||||
- **Yerel (native), Docker değil.** AmneziaWG, host üzerinde gerçek bir çekirdek arabirimi olarak çalışır; `awg-quick`/`awg` ile açılıp kapatılır — size yerel bir `wg0` arabirimi kazandıran aynı DKMS çekirdek modülü yaklaşımı. Ayrıcalıklı bir sidecar konteynerine gerek yoktur.
|
||||
- **Gömülü (embedded), çekirdek modülü değil.** AmneziaWG tamamen panel sürecinin kendi içinde çalışır ([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go), kullanıcı alanında çalışan bir ağ yığını üzerinden) — DKMS derlemesi yok, Secure Boot ile çakışma yok, ayrıcalıklı bir sidecar konteyner yok ve host üzerine kurulacak hiçbir şey yok.
|
||||
- **Birinci sınıf bir protokol.** Bir AmneziaWG gelen bağlantısı, diğerleriyle aynı `Inbound` tablosunda yaşar; bu sayede toplu işlemleri (bulk operations), QR kodu/yapılandırma indirme modalını ve abonelik bağlantılarını hiçbir ek çaba olmadan kazanır — öğrenilecek yeni bir şey yoktur.
|
||||
- **Tam AmneziaWG 2.0 gizlemesi** — Jc/Jmin/Jmax (çöp paketler), S1–S4 (paket dolgusu), H1–H4 (sihirli başlıklar) ve I1 imza paketi; her biri gelen bağlantı başına düzenlenebilir ve tek tıkla rastgeleleştirme düğmesine sahiptir, ayrıca eski istemciler için 1.x uyumlu bir mod da bulunur.
|
||||
- **Yerel IPv6**, kullanıcı başına NDP proxy desteğiyle; böylece her eş (peer) doğrudan erişilebilir bir IPv6 adresi alır — NAT66 gerekmez.
|
||||
- **Kullanıcı başına port yönlendirme** — belirli portları/aralıkları doğrudan bir eşin tünel adresine DNAT edin.
|
||||
- **Bir istemcinin trafiğini Xray üzerinden yönlendirme** — her AmneziaWG gelen bağlantısı otomatik olarak kendi loopback Xray köprüsünü alır (hiçbir anahtar/switch olmadan); herhangi bir istemcinin trafiğini, panelde zaten mevcut olan "Yönlendirme" sayfası üzerinden yapılandırılmış herhangi bir Xray giden bağlantısına, tıpkı başka herhangi bir protokolü yönlendirir gibi yönlendirin.
|
||||
- **`install.sh` çekirdek modülünü sizin için kurar** — Ubuntu/Debian/Armbian üzerinde (`ppa:amnezia/ppa`), diğer dağıtımlar için bir yedek (fallback) ile birlikte. Sizin için yapamayacağı tek şey: VPS/VM'inizde **Secure Boot'u önceden devre dışı bırakmak** — DKMS ile derlenmiş bir modül imzasızdır ve Secure Boot etkin olduğu sürece çekirdek onu yüklemeyi reddeder.
|
||||
- Uzlaştırma (reconcile), [`internal/mtproto`](internal/mtproto)'nun `mtg` sidecar'ını yönetme biçimiyle tamamen aynı şekilde yapılır: arka planda çalışan bir görev, çalışan arabirimi veritabanında saklanan durumla senkronize tutar ve mümkün olduğunda eş (peer) değişikliklerini tam bir arabirim yeniden başlatması yerine `awg syncconf` üzerinden uygular.
|
||||
- **Gerçek `vpn://` paylaşım bağlantıları** — istemci başına kopyalama bağlantısı/QR kodu ve abonelik uç noktası artık resmi AmneziaVPN uygulamasının beklediği gerçek `vpn://` şemasını üretiyor (düz bir `.conf` dosyasının base64url'i), uygulamanın içe aktaramadığı uydurma bir URI biçimi değil.
|
||||
- **Her istemcinin trafiği zaten Xray üzerinden geçiyor.** TPROXY yok, ayrıca etkinleştirilmesi gereken bir köprü yok: her AmneziaWG gelen bağlantısı, loopback üzerinden doğrudan kendi Xray SOCKS5 gelen bağlantısına aktarılır; bu sayede istemci başına trafik istatistikleri, çevrimiçi durumu, sniffing ve panelde zaten mevcut olan "Yönlendirme" sayfasının kuralları, ek bir yapılandırma gerekmeden, tıpkı başka herhangi bir protokolde olduğu gibi çalışır.
|
||||
- **Gerçek `vpn://` paylaşım bağlantıları** — istemci başına kopyalama bağlantısı/QR kodu ve abonelik uç noktası, resmi AmneziaVPN uygulamasının beklediği gerçek `vpn://` şemasını üretir (düz bir `.conf` dosyasının base64url'i), uygulamanın içe aktaramadığı uydurma bir URI biçimi değil.
|
||||
- **Bu geçişten sonra geçici olarak desteklenmiyor:** istemci başına ayrı bir genel (public) IPv6 adresi ve istemci başına port yönlendirmenin ikisi de eski çekirdek modülünün host düzeyindeki iptables kurallarına dayanıyordu; bunların gömülü mimaride henüz bir karşılığı yok. Her ikisi de yakın zamanda ayrı sürümler olarak planlanıyor; her ikisi için de mevcut ayarlar kaybolmadı, sadece o zamana kadar etkisiz durumdalar.
|
||||
|
||||
## Bu forktaki diğer değişiklikler
|
||||
|
||||
@@ -121,11 +118,11 @@ sona hiçbir soru sormadan tamamlanır, rastgele kimlik bilgileri oluşturup bun
|
||||
|
||||
## Desteklenen Platformlar
|
||||
|
||||
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) ve Alpine. (Orijinal proje ayrıca bir Windows sürümü de yayınlar; bu fork'un CI'ı bunu yapmaz — buradaki her şey Linux çalıştıran sunucuları/yönlendiricileri hedefler ve AmneziaWG zaten her durumda bir Linux çekirdek modülüne ihtiyaç duyar.)
|
||||
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap) ve Alpine. (Orijinal proje ayrıca bir Windows sürümü de yayınlar; bu fork'un CI'ı bunu yapmaz — buradaki her şey Linux çalıştıran sunucuları/yönlendiricileri hedefler.)
|
||||
|
||||
**Mimariler:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
AmneziaWG özellikle gerçek bir Linux çekirdeği ve AmneziaWG'ye özgü DKMS çekirdek modülüne ihtiyaç duyar — Windows üzerinde çalışmaz ve `install_amneziawg` bugün yalnızca Ubuntu/Debian/Armbian üzerinde çekirdek modülü kurulumunu otomatikleştirir ([Bu fork'ta ne farklı](#bu-forkta-ne-farklı-amneziawg) bölümüne bakın).
|
||||
AmneziaWG doğrudan panel ikili dosyasının (binary) kendi içine gömülüdür ([Bu fork'ta ne farklı](#bu-forkta-ne-farklı-amneziawg) bölümüne bakın) — çekirdek modülü yok, ayrı bir kurulum adımı yok, dağıtıma özgü bir kurulum yok.
|
||||
|
||||
## Veritabanı Seçenekleri
|
||||
|
||||
@@ -195,10 +192,11 @@ Bu fork tamamen [MHSanaei/3x-ui](https://github.com/MHSanaei/3x-ui) üzerine in
|
||||
<img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
|
||||
</a>
|
||||
|
||||
Bu fork'taki yerel AmneziaWG uygulaması şunlardan alınmış/ilham almıştır:
|
||||
Bu fork'taki AmneziaWG uygulaması şunlardan alınmış/ilham almıştır:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — orijinal projeye karşı açılan orijinal AmneziaWG pull request'i (Docker sidecar yaklaşımı); bu fork onun şema/ön yüz (frontend) yapısını yeniden kullanır ancak arka ucu (backend) yerel, Docker'sız bir yöneticiyle değiştirir.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — üretimde zaten yerel AmneziaWG çalıştıran bağımsız bir fork; bu fork'un `awg-quick` süreç yönetimi, yapılandırma üretimi ve AmneziaWG 2.0 gizleme parametresi üreticisi onun `awg/` paketinden alınmıştır.
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — bu forkun, aşağıdaki eski çekirdek-modülü tabanlı arka ucun yerine, doğrudan panel süreci içine, bir [gVisor](https://gvisor.dev/) ağ yığını üzerinden gömdüğü kullanıcı alanı AmneziaWG uygulaması.
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — orijinal projeye karşı açılan orijinal AmneziaWG pull request'i (Docker sidecar yaklaşımı); bu fork onun şema/ön yüz (frontend) yapısını yeniden kullanır.
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — üretimde zaten yerel AmneziaWG çalıştıran bağımsız bir fork; bu fork'un bu yeniden yazımdan önceki çekirdek modülü tabanlı (`awg-quick`) yöneticisi ve AmneziaWG 2.0 gizleme parametresi üreticisi onun `awg/` paketinden alınmıştı.
|
||||
|
||||
## Özel Teşekkürler
|
||||
|
||||
|
||||
+10
-12
@@ -24,15 +24,12 @@
|
||||
|
||||
[AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) 是 WireGuard 的一个变体,增加了一层混淆(垃圾数据包、随机填充、重写魔术头部),旨在击败基于 DPI 的协议指纹识别——同样的隧道,但在线路上不再表现得像一条隧道。
|
||||
|
||||
- **原生实现,而非 Docker。** AmneziaWG 作为真正的内核接口运行在宿主机上,通过 `awg-quick`/`awg` 启动和停止——采用与拥有原生 `wg0` 接口相同的 DKMS 内核模块方案。无需特权 sidecar 容器。
|
||||
- **内置实现,而非内核模块。** AmneziaWG 完全在面板进程内部运行([amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) 基于用户态网络协议栈实现)——无需编译 DKMS 模块,不与 Secure Boot 冲突,无需特权 sidecar 容器,宿主机上完全不需要安装任何东西。
|
||||
- **一等协议。** AmneziaWG 入站与其他协议共享同一张 `Inbound` 表,因此可以免费获得批量操作、二维码/配置下载弹窗以及订阅链接——无需学习任何新东西。
|
||||
- **完整的 AmneziaWG 2.0 混淆功能**——Jc/Jmin/Jmax(垃圾数据包)、S1–S4(数据包填充)、H1–H4(魔术头部)以及 I1 签名数据包,均可按入站单独编辑,并提供一键随机化按钮,同时为旧版客户端提供 1.x 兼容模式。
|
||||
- **原生 IPv6**,支持按客户端的 NDP 代理,使每个对等端都获得可直接访问的 IPv6 地址——无需 NAT66。
|
||||
- **按客户端端口转发**——将特定端口/端口范围直接 DNAT 到某个对等端的隧道地址。
|
||||
- **将客户端流量通过 Xray 路由**——每个 AmneziaWG 入站都会自动获得属于自己的本地回环 Xray 网桥(无需任何开关);通过面板中已有的"路由"页面,将任意客户端的流量路由到任意已配置的 Xray 出站,方式与路由其他协议完全相同。
|
||||
- **`install.sh` 会为您安装内核模块**,适用于 Ubuntu/Debian/Armbian(`ppa:amnezia/ppa`),其他发行版则有回退方案。它唯一无法为您做的事:预先**禁用 VPS/VM 上的 Secure Boot**——DKMS 构建的模块未经签名,只要 Secure Boot 处于启用状态,内核就会拒绝加载它。
|
||||
- 协调(reconcile)方式与 [`internal/mtproto`](internal/mtproto) 管理 `mtg` sidecar 的方式完全相同:一个后台任务持续保持运行中的接口与数据库中存储的内容同步,并尽可能通过 `awg syncconf` 而非完整的接口重启来应用对等端变更。
|
||||
- **真正的 `vpn://` 分享链接** — 每个客户端的复制链接/二维码以及订阅端点现在会生成官方 AmneziaVPN 应用真正期望的 `vpn://` 格式(纯文本 `.conf` 的 base64url 编码),而不是该应用无法导入的自造 URI 格式。
|
||||
- **每个客户端的流量已经在通过 Xray。** 无需 TPROXY,也无需另行启用网桥:每个 AmneziaWG 入站都会通过本地回环直接转发到其专属的 Xray SOCKS5 入站,因此按客户端的流量统计、在线状态、sniffing,以及面板中已有的"路由"页面规则,都会像其他任何协议一样自动生效——无需额外配置。
|
||||
- **真正的 `vpn://` 分享链接** — 每个客户端的复制链接/二维码以及订阅端点会生成官方 AmneziaVPN 应用真正期望的 `vpn://` 格式(纯文本 `.conf` 的 base64url 编码),而不是该应用无法导入的自造 URI 格式。
|
||||
- **本次迁移之后暂不支持**:每个客户端独立的公网 IPv6 地址,以及按客户端的端口转发,二者都依赖于旧内核模块在宿主机层面的 iptables 规则,而内置架构目前还没有与之等效的实现。两者均已计划在后续版本中推出;已保存的相关设置不会丢失,只是在此之前处于未生效状态。
|
||||
|
||||
## 本分支的其他更改
|
||||
|
||||
@@ -121,11 +118,11 @@ curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bas
|
||||
|
||||
## 支持的平台
|
||||
|
||||
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap) 和 Alpine。(原项目也发布 Windows 版本;本分支的 CI 不这样做——这里的一切都面向运行 Linux 的服务器/路由器,而且 AmneziaWG 无论如何都需要 Linux 内核模块。)
|
||||
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap) 和 Alpine。(原项目也发布 Windows 版本;本分支的 CI 不这样做——这里的一切都面向运行 Linux 的服务器/路由器。)
|
||||
|
||||
**架构:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`。
|
||||
|
||||
AmneziaWG 特别需要真正的 Linux 内核以及 AmneziaWG 专用的 DKMS 内核模块——它无法在 Windows 上运行,而目前 `install_amneziawg` 只能在 Ubuntu/Debian/Armbian 上自动完成内核模块安装(参见[本分支的不同之处](#本分支的不同之处amneziawg)一节)。
|
||||
AmneziaWG 直接内置于面板二进制文件本身(参见[本分支的不同之处](#本分支的不同之处amneziawg)一节)——无需内核模块,无需单独的安装步骤,也无需针对特定发行版的设置。
|
||||
|
||||
## 数据库选项
|
||||
|
||||
@@ -195,10 +192,11 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
本分支中原生 AmneziaWG 的实现参考/借鉴自:
|
||||
本分支中 AmneziaWG 的实现参考/借鉴自:
|
||||
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — 针对原项目提出的原始 AmneziaWG PR(Docker sidecar 方案);本分支重用了其 schema/前端结构,但将后端替换为原生、无 Docker 的管理器。
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — 一个独立的分支,已经在生产环境中运行原生 AmneziaWG;本分支中 `awg-quick` 进程管理、配置生成以及 AmneziaWG 2.0 混淆参数生成器均源自其 `awg/` 包。
|
||||
- [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) — 本分支直接内嵌到面板进程中的用户态 AmneziaWG 实现,基于 [gVisor](https://gvisor.dev/) 网络协议栈,取代了下方原先基于内核模块的后端。
|
||||
- [MHSanaei/3x-ui#6086](https://github.com/MHSanaei/3x-ui/pull/6086) — 针对原项目提出的原始 AmneziaWG PR(Docker sidecar 方案);本分支重用了其 schema/前端结构。
|
||||
- [coinman-dev/3ax-ui](https://github.com/coinman-dev/3ax-ui) — 一个独立的分支,已经在生产环境中运行原生 AmneziaWG;本分支在此次重写之前基于内核模块的 (`awg-quick`) 管理器以及 AmneziaWG 2.0 混淆参数生成器均源自其 `awg/` 包。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
|
||||
@@ -2857,7 +2857,7 @@
|
||||
"description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
|
||||
"properties": {
|
||||
"externalInterface": {
|
||||
"description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.",
|
||||
"description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
|
||||
"type": "string"
|
||||
},
|
||||
"h1": {
|
||||
@@ -2876,7 +2876,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ipv6Enabled": {
|
||||
"description": "IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is\nallocated from IPv6Subnet alongside each client's IPv4 one, and the\nserver proxies NDP for each enabled client's address so upstream\nrouters see it as directly reachable (no NAT66). IPv6ExternalInterface\noverrides ExternalInterface for the NDP-proxy PostUp/PostDown entries\nspecifically; empty reuses ExternalInterface.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"ipv6ExternalInterface": {
|
||||
@@ -2909,7 +2908,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"s1": {
|
||||
|
||||
@@ -2831,7 +2831,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
|
||||
"properties": {
|
||||
"externalInterface": {
|
||||
"description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.",
|
||||
"description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
|
||||
"type": "string"
|
||||
},
|
||||
"h1": {
|
||||
@@ -2850,7 +2850,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"ipv6Enabled": {
|
||||
"description": "IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is\nallocated from IPv6Subnet alongside each client's IPv4 one, and the\nserver proxies NDP for each enabled client's address so upstream\nrouters see it as directly reachable (no NAT66). IPv6ExternalInterface\noverrides ExternalInterface for the NDP-proxy PostUp/PostDown entries\nspecifically; empty reuses ExternalInterface.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"ipv6ExternalInterface": {
|
||||
@@ -2883,7 +2882,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"routeThroughXray": {
|
||||
"description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"s1": {
|
||||
|
||||
@@ -3,7 +3,6 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type ensureAction = number;
|
||||
export type geodataFileKind = number;
|
||||
export type staticEgressResolver = string;
|
||||
export type transportBits = number;
|
||||
|
||||
@@ -12,9 +12,6 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const ensureActionSchema = z.number().int();
|
||||
export type ensureAction = z.infer<typeof ensureActionSchema>;
|
||||
|
||||
export const geodataFileKindSchema = z.number().int();
|
||||
export type geodataFileKind = z.infer<typeof geodataFileKindSchema>;
|
||||
|
||||
|
||||
@@ -298,7 +298,6 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
|
||||
ipv6Enabled: false,
|
||||
ipv6Subnet: '',
|
||||
ipv6ExternalInterface: '',
|
||||
routeThroughXray: false,
|
||||
jc: 5,
|
||||
jmin: 10,
|
||||
jmax: 50,
|
||||
|
||||
@@ -68,14 +68,6 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou
|
||||
>
|
||||
<Input placeholder="eth0" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'server', 'routeThroughXray']}
|
||||
label={t('pages.xray.amneziawg.routeThroughXray')}
|
||||
extra={t('pages.xray.amneziawg.routeThroughXrayHint')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
||||
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
||||
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
||||
|
||||
@@ -35,11 +35,18 @@ export const AmneziawgClientSchema = z.object({
|
||||
export type AmneziawgClient = z.infer<typeof AmneziawgClientSchema>;
|
||||
|
||||
// Server-wide AmneziaWG 2.0 obfuscation parameters and tunnel identity,
|
||||
// mirroring internal/amneziawg.ServerSettings on the Go side exactly (same
|
||||
// field names) — the listen port is not duplicated here, it's the inbound's
|
||||
// own port like every other protocol. H1-H4 blank falls back to the classic
|
||||
// mirroring internal/amneziawg.ServerSettings on the Go side (same field
|
||||
// names) — the listen port is not duplicated here, it's the inbound's own
|
||||
// port like every other protocol. H1-H4 blank falls back to the classic
|
||||
// 1/2/3/4 magic header on save; I1 blank omits the 2.0-only CPS signature
|
||||
// packet (a 1.x-compatible config).
|
||||
//
|
||||
// Deliberately missing routeThroughXray: that field is vestigial on the Go
|
||||
// side too, as of the hard cutover to the embedded (amneziawg-go) path --
|
||||
// see ServerSettings' own doc comment. Omitting it here means z.object's
|
||||
// default unknown-key stripping quietly drops it from an existing stored
|
||||
// settings blob on the next save, rather than the form round-tripping a
|
||||
// value nothing reads anymore.
|
||||
export const AmneziawgServerSchema = z.object({
|
||||
privateKey: z.string().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
@@ -52,7 +59,6 @@ export const AmneziawgServerSchema = z.object({
|
||||
ipv6Enabled: z.boolean().default(false),
|
||||
ipv6Subnet: z.string().default(''),
|
||||
ipv6ExternalInterface: z.string().default(''),
|
||||
routeThroughXray: z.boolean().default(false),
|
||||
jc: z.number().int().min(0).default(5),
|
||||
jmin: z.number().int().min(0).default(10),
|
||||
jmax: z.number().int().min(0).default(50),
|
||||
|
||||
@@ -35,6 +35,8 @@ require (
|
||||
pgregory.net/rapid v1.3.0
|
||||
)
|
||||
|
||||
require github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
@@ -110,6 +112,6 @@ require (
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3 h1:XYR85mN53hj2DTzToHs3OxIHrNA59QMg1m3+oiOnBi4=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
|
||||
|
||||
-265
@@ -123,234 +123,6 @@ install_base() {
|
||||
esac
|
||||
}
|
||||
|
||||
url_reachable() {
|
||||
curl --connect-timeout 5 --max-time 10 -sSIL -o /dev/null "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Probes URL reachability before relying on it (namely the AmneziaWG PPA host,
|
||||
# which hosting providers — especially Russian VPS — frequently block).
|
||||
# Non-interactive installs always skip-and-continue rather than block on a
|
||||
# prompt; interactive installs ask, defaulting to skip so a flaky network
|
||||
# doesn't abort the whole run over one optional feature.
|
||||
check_url_or_skip() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
if url_reachable "$url"; then
|
||||
return 0
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${yellow}══════════════════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow} Failed to reach: ${url}${plain}"
|
||||
echo -e "${yellow} Module / file: ${label}${plain}"
|
||||
echo -e "${yellow}══════════════════════════════════════════════════════${plain}"
|
||||
if [[ "$NONINTERACTIVE" == "1" ]]; then
|
||||
echo -e "${yellow}Non-interactive install: skipping ${label}.${plain}"
|
||||
return 1
|
||||
fi
|
||||
read -rp "Continue without it? [Y/n]: " __skip_choice
|
||||
case "${__skip_choice,,}" in
|
||||
n | no)
|
||||
echo -e "${red}Aborted by user.${plain}"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo -e "${yellow}Skipping ${label}.${plain}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Installs ndppd (IPv6 NDP proxy), used by a future AmneziaWG IPv6 mode so
|
||||
# clients can get a native public IPv6 address without NAT66. Not wired into
|
||||
# the panel yet (tracked separately) — installed now so it's already in place
|
||||
# once that lands. Best-effort: never fatal.
|
||||
install_ndppd() {
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get install -y -q ndppd 2>/dev/null || true
|
||||
;;
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
|
||||
dnf install -y ndppd 2>/dev/null || yum install -y ndppd 2>/dev/null || true
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
# -Sy (not -Syu): every other pacman call in this script only
|
||||
# refreshes the package database, never does a full system
|
||||
# upgrade as a side effect of installing one package.
|
||||
pacman -Sy --noconfirm ndppd 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Persists IPv4/IPv6 forwarding across reboots. AmneziaWG's own PostUp already
|
||||
# sets net.ipv4.ip_forward=1 for the current boot (see
|
||||
# internal/amneziawg/manager.go's defaultPostUpDown), so this is a belt-and-
|
||||
# suspenders persistence step, not the only place it's set.
|
||||
enable_ipv6_forwarding() {
|
||||
# Checking /etc/sysctl.conf by name is not reliable: many distros split
|
||||
# sysctl settings across /etc/sysctl.d/*.conf, and /etc/sysctl.conf is
|
||||
# sometimes just a symlink into that directory, so grep can miss an
|
||||
# already-active setting (false negative -> harmless duplicate line) or
|
||||
# match a disabled/commented one (false positive -> forwarding silently
|
||||
# stays off). Querying the live value directly is accurate regardless of
|
||||
# which file actually set it.
|
||||
if [ "$(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null)" != "1" ]; then
|
||||
echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.conf
|
||||
fi
|
||||
if [ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" != "1" ]; then
|
||||
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
|
||||
fi
|
||||
sysctl -p >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Loads the mainline TPROXY kernel modules, used by AmneziaWG's optional
|
||||
# per-client "route via Xray" toggle (see internal/amneziawg's EgressPort and
|
||||
# defaultPostUpDown's `-j TPROXY` rules). Unlike the AmneziaWG module itself,
|
||||
# these are standard upstream modules present on any modern distro kernel —
|
||||
# no DKMS/PPA needed, just loading them. Best-effort: a panel without them
|
||||
# still works fine, that one toggle just won't redirect traffic until
|
||||
# they're available.
|
||||
enable_tproxy_support() {
|
||||
modprobe xt_TPROXY 2>/dev/null || true
|
||||
modprobe nf_tproxy_ipv4 2>/dev/null || true
|
||||
modprobe nf_tproxy_ipv6 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Installs the AmneziaWG DKMS kernel module + amneziawg-tools (awg/awg-quick)
|
||||
# so an AmneziaWG inbound created in the panel can actually bring up an
|
||||
# interface. Best-effort and never fatal to the overall x-ui install: the
|
||||
# panel works fine without it, an AmneziaWG inbound just won't start its
|
||||
# tunnel until the module is installed (surfaced in the panel/logs, not here).
|
||||
# AmneziaWG is this fork's signature feature, so it installs by default on
|
||||
# every install/migration/update (see should_install_amneziawg below) --
|
||||
# opt-out, not opt-in, via XUI_INSTALL_AMNEZIAWG=false for anyone who
|
||||
# specifically doesn't want the DKMS kernel module + host-wide IPv4/IPv6
|
||||
# forwarding it brings.
|
||||
#
|
||||
# should_install_amneziawg decides whether to run install_amneziawg at all.
|
||||
# Short-circuits to yes when awg is already on PATH, so `x-ui update` on a
|
||||
# host that already has it doesn't re-prompt an admin who already answered
|
||||
# this once -- install_amneziawg's own case statement would just skip the
|
||||
# actual DKMS/package work again anyway, but the interactive prompt itself
|
||||
# still fired every run, and answering "n" out of habit (since AmneziaWG is
|
||||
# already installed and working) skipped the harmless modprobe/ndppd/sysctl
|
||||
# refresh that same case statement also does unconditionally.
|
||||
# XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/
|
||||
# cloud-init runs); otherwise an interactive install prompts (default: yes),
|
||||
# and a non-interactive one with nothing to answer the prompt defaults to
|
||||
# installing it too.
|
||||
should_install_amneziawg() {
|
||||
command -v awg &>/dev/null && return 0
|
||||
case "${XUI_INSTALL_AMNEZIAWG:-}" in
|
||||
true | TRUE | 1 | yes | y | Y) return 0 ;;
|
||||
false | FALSE | 0 | no | n | N) return 1 ;;
|
||||
esac
|
||||
if [[ "$NONINTERACTIVE" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local reply
|
||||
read -rp "Install native AmneziaWG support (WireGuard + DPI-resistant obfuscation)? This builds a DKMS kernel module and enables host-wide IP forwarding. (Y/n): " reply
|
||||
[[ -z "$reply" || "$reply" == "y" || "$reply" == "Y" ]]
|
||||
}
|
||||
|
||||
# ppa:amnezia/ppa (Ubuntu/Debian/Armbian) is the primary, tested path; other
|
||||
# distros fall back to plain wireguard-tools with a manual-install pointer.
|
||||
# See https://github.com/amnezia-vpn/amneziawg-linux-kernel-module.
|
||||
#
|
||||
# Also requires Secure Boot to be OFF (checked separately, see
|
||||
# check_secure_boot below) — a DKMS-built module is unsigned and the kernel
|
||||
# refuses to load it while Secure Boot is enforced.
|
||||
install_amneziawg() {
|
||||
if command -v awg &>/dev/null; then
|
||||
echo -e "${green}AmneziaWG (awg) already installed.${plain}"
|
||||
modprobe amneziawg 2>/dev/null || true
|
||||
install_ndppd
|
||||
enable_ipv6_forwarding
|
||||
enable_tproxy_support
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${green}Installing AmneziaWG...${plain}"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export DEBCONF_NONINTERACTIVE_SEEN=true
|
||||
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
if ! check_url_or_skip "https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/focal/Release" "AmneziaWG (ppa.launchpadcontent.net)"; then
|
||||
echo -e "${yellow}Install it manually later if needed:${plain}"
|
||||
echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
install_ndppd
|
||||
return
|
||||
fi
|
||||
echo -e "${yellow}Installing amneziawg from ppa:amnezia/ppa...${plain}"
|
||||
apt-get install -y -q software-properties-common python3-launchpadlib gnupg2 "linux-headers-$(uname -r)" 2>/dev/null || true
|
||||
# Ensure deb-src is present (required for the PPA's DKMS build).
|
||||
if ! grep -q "^deb-src" /etc/apt/sources.list 2>/dev/null; then
|
||||
grep "^deb " /etc/apt/sources.list | sed 's/^deb /deb-src /' >> /etc/apt/sources.list
|
||||
fi
|
||||
if [[ "${release}" == "ubuntu" ]]; then
|
||||
add-apt-repository -y ppa:amnezia/ppa 2>/dev/null &&
|
||||
apt-get update -q &&
|
||||
apt-get install -y amneziawg &&
|
||||
echo -e "${green}AmneziaWG installed successfully via PPA.${plain}" ||
|
||||
echo -e "${red}PPA install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
else
|
||||
# apt-key is deprecated/removed on Debian 12+ and Ubuntu 24.04;
|
||||
# fetch the key into its own keyring file and reference it via
|
||||
# signed-by= instead of the removed system-wide trust store.
|
||||
local amneziawg_keyring="/etc/apt/keyrings/amneziawg.gpg"
|
||||
local amneziawg_list_entry="deb [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main"
|
||||
local amneziawg_src_entry="deb-src [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main"
|
||||
install -d -m 755 /etc/apt/keyrings
|
||||
gpg --no-default-keyring --keyring "$amneziawg_keyring" --keyserver keyserver.ubuntu.com --recv-keys 57290828 2>/dev/null || true
|
||||
# Guarded so a retried install (the PPA step failed last time,
|
||||
# or install.sh simply ran again) doesn't keep appending
|
||||
# duplicate sources.list entries.
|
||||
grep -qxF "$amneziawg_list_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_list_entry" >> /etc/apt/sources.list
|
||||
grep -qxF "$amneziawg_src_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_src_entry" >> /etc/apt/sources.list
|
||||
apt-get update -q &&
|
||||
apt-get install -y amneziawg &&
|
||||
echo -e "${green}AmneziaWG installed successfully.${plain}" ||
|
||||
echo -e "${red}Install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
fi
|
||||
modprobe amneziawg 2>/dev/null || true
|
||||
install_ndppd
|
||||
;;
|
||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
|
||||
echo -e "${yellow}AmneziaWG has no prebuilt package for ${release}. Installing WireGuard as a fallback...${plain}"
|
||||
dnf install -y -q wireguard-tools 2>/dev/null || yum install -y wireguard-tools 2>/dev/null || true
|
||||
echo -e "${yellow}Note: for full AmneziaWG (obfuscated) support, install amneziawg-tools manually:${plain}"
|
||||
echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
install_ndppd
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Sy --noconfirm wireguard-tools 2>/dev/null || true
|
||||
if command -v yay &>/dev/null; then
|
||||
yay -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true
|
||||
elif command -v paru &>/dev/null; then
|
||||
paru -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true
|
||||
else
|
||||
echo -e "${yellow}Install an AUR helper (yay/paru) for amneziawg-dkms, or build it manually:${plain}"
|
||||
echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
fi
|
||||
install_ndppd
|
||||
;;
|
||||
*)
|
||||
echo -e "${yellow}${release}: no automated AmneziaWG install path. Install it manually if needed:${plain}"
|
||||
echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v awg &>/dev/null; then
|
||||
echo -e "${green}awg: $(awg --version 2>/dev/null || echo 'installed')${plain}"
|
||||
else
|
||||
echo -e "${yellow}Warning: 'awg' binary not found. The panel will work, but an AmneziaWG${plain}"
|
||||
echo -e "${yellow}inbound's tunnel will not start until you install it manually.${plain}"
|
||||
fi
|
||||
|
||||
enable_ipv6_forwarding
|
||||
enable_tproxy_support
|
||||
}
|
||||
|
||||
gen_random_string() {
|
||||
local length="$1"
|
||||
openssl rand -base64 $((length * 2)) \
|
||||
@@ -1966,41 +1738,4 @@ install_x-ui() {
|
||||
|
||||
echo -e "${green}Running...${plain}"
|
||||
install_base
|
||||
if should_install_amneziawg; then
|
||||
install_amneziawg
|
||||
else
|
||||
echo -e "${yellow}Skipping AmneziaWG setup. To install it later, re-run with the variable${plain}"
|
||||
echo -e "${yellow}exported first (a piped 'VAR=val curl ... | bash' only sets it for curl,${plain}"
|
||||
echo -e "${yellow}not for bash -- export it in the current shell instead):${plain}"
|
||||
echo -e "${yellow} export XUI_INSTALL_AMNEZIAWG=true${plain}"
|
||||
echo -e "${yellow} curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bash${plain}"
|
||||
echo -e "${yellow}...or install it manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}"
|
||||
fi
|
||||
install_x-ui $1
|
||||
|
||||
# Secure Boot blocks the AmneziaWG DKMS module from loading (it's unsigned).
|
||||
# Try mokutil first, fall back to reading the EFI variable directly.
|
||||
check_secure_boot() {
|
||||
if command -v mokutil &>/dev/null; then
|
||||
mokutil --sb-state 2>/dev/null | grep -q "SecureBoot enabled"
|
||||
return $?
|
||||
fi
|
||||
local sb_var
|
||||
sb_var=$(find /sys/firmware/efi/efivars -name "SecureBoot-*" 2>/dev/null | head -1)
|
||||
if [[ -n "$sb_var" ]]; then
|
||||
[[ "$(od -An -tu1 -j4 -N1 "$sb_var" 2>/dev/null | tr -d ' ')" == "1" ]]
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if command -v awg &>/dev/null && check_secure_boot; then
|
||||
echo -e ""
|
||||
echo -e "${red}[!] WARNING: Secure Boot is ENABLED${plain}"
|
||||
echo -e "${yellow}AmneziaWG's kernel module is unsigned and cannot load while Secure Boot${plain}"
|
||||
echo -e "${yellow}is active — AmneziaWG tunnels will NOT work until it is disabled.${plain}"
|
||||
echo -e "${yellow}Fix: turn off Secure Boot in your VPS provider's control panel, or in${plain}"
|
||||
echo -e "${yellow}the VM's firmware/BIOS settings, then reboot. No reinstall needed${plain}"
|
||||
echo -e "${yellow}afterward — AmneziaWG will start working on its own.${plain}"
|
||||
echo -e ""
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
|
||||
// (Instance, Peer, Obfuscation20, ServerSettings/InboundSettings) and the
|
||||
// pure functions that derive an Instance from a stored inbound row. It no
|
||||
// longer manages any OS-level interface itself: that was the kernel-module
|
||||
// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
|
||||
// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
|
||||
// netstack, see internal/amneziawgnet) in a hard cutover. This package's
|
||||
// remaining code is deliberately protocol-shape-only, with no OS dependency
|
||||
// at all, so both the (now-removed) kernel-module path and the embedded
|
||||
// path could read -- and, historically, did read -- it identically.
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
|
||||
// building one peer per active client. Returns false when the inbound is not
|
||||
// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
|
||||
// server block) or has no enabled peer to serve — mirroring
|
||||
// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
|
||||
// run it with nothing to serve.
|
||||
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
if ib == nil || ib.Protocol != model.AmneziaWG {
|
||||
return Instance{}, false
|
||||
}
|
||||
var parsed InboundSettings
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
|
||||
return Instance{}, false
|
||||
}
|
||||
server := parsed.Server
|
||||
|
||||
peers := make([]Peer, 0, len(parsed.Clients))
|
||||
for _, c := range parsed.Clients {
|
||||
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, Peer{
|
||||
Email: c.Email,
|
||||
PublicKey: c.PublicKey,
|
||||
PresharedKey: c.PreSharedKey,
|
||||
AllowedIPs: c.AllowedIPs,
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
})
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return Instance{}, false
|
||||
}
|
||||
|
||||
addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
|
||||
if server.IPv6Enabled {
|
||||
if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
|
||||
addresses = append(addresses, v6)
|
||||
}
|
||||
}
|
||||
|
||||
return Instance{
|
||||
Id: ib.Id,
|
||||
Tag: ib.Tag,
|
||||
InterfaceName: interfaceNameForID(ib.Id),
|
||||
ListenPort: ib.Port,
|
||||
PrivateKey: server.PrivateKey,
|
||||
PublicKey: server.PublicKey,
|
||||
Address: addresses,
|
||||
MTU: server.MTU,
|
||||
Obfuscation: server.Obfuscation(),
|
||||
Peers: peers,
|
||||
ExternalInterface: server.ExternalInterface,
|
||||
IPv6Enabled: server.IPv6Enabled,
|
||||
IPv6ExternalInterface: server.IPv6ExternalInterface,
|
||||
RouteThroughXray: server.RouteThroughXray,
|
||||
}, true
|
||||
}
|
||||
|
||||
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
|
||||
// "awg42". Kept even though the embedded path has no real kernel interface
|
||||
// of its own: internal/amneziawgnet still uses the same name as a purely
|
||||
// cosmetic/log-friendly label, so an existing peer's identity/history
|
||||
// doesn't shift across the cutover.
|
||||
func interfaceNameForID(id int) string {
|
||||
return fmt.Sprintf("awg%d", id)
|
||||
}
|
||||
|
||||
// serverAddress returns the server's own tunnel address for a subnet base,
|
||||
// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
|
||||
// holds the first usable host of the network subnetIP/cidr actually
|
||||
// describes -- derived via netip rather than assuming subnetIP already ends
|
||||
// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
|
||||
// manually edited value) can never collide with peer addresses, which are
|
||||
// allocated starting from the network's second host upward (see
|
||||
// allocateWireguardAddress). Falls back to the previous literal behavior
|
||||
// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
|
||||
// saves never reach that path since ValidateSubnetIPv4 already rejects it.
|
||||
func serverAddress(subnetIP string, cidr int) string {
|
||||
if cidr <= 0 {
|
||||
cidr = 24
|
||||
}
|
||||
// A /32 has no host bits at all -- "first usable host" is meaningless,
|
||||
// and Next() would step outside the block entirely -- so a single-host
|
||||
// base is used exactly as given, same as before this fix.
|
||||
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
|
||||
if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
|
||||
return fmt.Sprintf("%s/%d", subnetIP, cidr)
|
||||
}
|
||||
host := prefix.Masked().Addr().Next()
|
||||
return fmt.Sprintf("%s/%d", host, cidr)
|
||||
}
|
||||
|
||||
// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
|
||||
// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
|
||||
// usable host in the prefix. ok is false when subnetCIDR is empty or not a
|
||||
// valid IPv6 prefix.
|
||||
func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
|
||||
prefix, err := netip.ParsePrefix(subnetCIDR)
|
||||
if err != nil || !prefix.Addr().Is6() {
|
||||
return "", false
|
||||
}
|
||||
host := prefix.Masked().Addr().Next()
|
||||
return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
|
||||
}
|
||||
|
||||
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
|
||||
// or "" if none — used by internal/web/service/server.go's
|
||||
// amneziawgEmailIndex to derive a peer's tunnel IPv4 address for the panel's
|
||||
// access-log viewer.
|
||||
func FirstIPv4(allowedIPs []string) string {
|
||||
for _, a := range allowedIPs {
|
||||
if prefix, err := netip.ParsePrefix(a); err == nil {
|
||||
if prefix.Addr().Is4() {
|
||||
return prefix.Addr().String()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
|
||||
return addr.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
|
||||
// or "" if none — the IPv6 counterpart of FirstIPv4, used by
|
||||
// internal/amneziawgnet's IPv6-address-alias mechanism to find which
|
||||
// address, if any, a peer wants aliased onto the host, and by
|
||||
// internal/web/service/xray.go's injectAmneziawgV6Egress to build that
|
||||
// peer's own freedom outbound (sendThrough). Only the first match is
|
||||
// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry
|
||||
// per peer is not a supported configuration for either feature.
|
||||
func FirstIPv6(allowedIPs []string) string {
|
||||
for _, a := range allowedIPs {
|
||||
if prefix, err := netip.ParsePrefix(a); err == nil {
|
||||
if prefix.Addr().Is6() && !prefix.Addr().Is4In6() {
|
||||
return prefix.Addr().String()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() {
|
||||
return addr.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
|
||||
t.Helper()
|
||||
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
return string(bs)
|
||||
}
|
||||
|
||||
func validServer() *ServerSettings {
|
||||
return &ServerSettings{
|
||||
PrivateKey: "serverPriv",
|
||||
PublicKey: "serverPub",
|
||||
SubnetIP: "10.8.1.0",
|
||||
SubnetCIDR: 24,
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
||||
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
|
||||
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
|
||||
})
|
||||
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
|
||||
|
||||
inst, ok := InstanceFromInbound(ib)
|
||||
if !ok {
|
||||
t.Fatal("expected a usable instance")
|
||||
}
|
||||
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
|
||||
t.Fatalf("instance identity not carried over: %+v", inst)
|
||||
}
|
||||
if inst.InterfaceName != "awg7" {
|
||||
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
|
||||
}
|
||||
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
|
||||
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
|
||||
}
|
||||
if len(inst.Peers) != 1 {
|
||||
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
|
||||
}
|
||||
p := inst.Peers[0]
|
||||
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
|
||||
t.Fatalf("peer mismatch: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("non-AmneziaWG inbound must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsNil(t *testing.T) {
|
||||
if _, ok := InstanceFromInbound(nil); ok {
|
||||
t.Fatal("nil inbound must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("settings with no server block must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("unparseable settings must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
subnet string
|
||||
cidr int
|
||||
want string
|
||||
}{
|
||||
{"10.8.1.0", 24, "10.8.1.1/24"},
|
||||
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
|
||||
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
|
||||
{"10.8.1.254", 24, "10.8.1.1/24"},
|
||||
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := serverAddress(c.subnet, c.cidr); got != c.want {
|
||||
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameForID(t *testing.T) {
|
||||
if got := interfaceNameForID(42); got != "awg42" {
|
||||
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstIPv4(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ips []string
|
||||
want string
|
||||
}{
|
||||
{"single v4 CIDR", []string{"10.8.1.2/32"}, "10.8.1.2"},
|
||||
{"bare v4 address, no mask", []string{"10.8.1.2"}, "10.8.1.2"},
|
||||
{"v6 first, v4 second", []string{"fd86:ea04:1115::2/128", "10.8.1.2/32"}, "10.8.1.2"},
|
||||
{"v4-only among several", []string{"10.8.1.2/32", "10.8.1.3/32"}, "10.8.1.2"},
|
||||
{"v6 only", []string{"fd86:ea04:1115::2/128"}, ""},
|
||||
{"empty input", nil, ""},
|
||||
{"unparseable entries skipped", []string{"not-an-ip", "10.8.1.2/32"}, "10.8.1.2"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FirstIPv4(c.ips); got != c.want {
|
||||
t.Errorf("%s: FirstIPv4(%v) = %q, want %q", c.name, c.ips, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstIPv6(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ips []string
|
||||
want string
|
||||
}{
|
||||
{"single v6 CIDR", []string{"fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
|
||||
{"bare v6 address, no mask", []string{"fd86:ea04:1115::2"}, "fd86:ea04:1115::2"},
|
||||
{"v4 first, v6 second", []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
|
||||
{"only first of two v6 entries returned", []string{"fd86:ea04:1115::2/128", "fd86:ea04:1115::3/128"}, "fd86:ea04:1115::2"},
|
||||
{"v4 only", []string{"10.8.1.2/32"}, ""},
|
||||
{"empty input", nil, ""},
|
||||
{"unparseable entries skipped", []string{"not-an-ip", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
|
||||
{"v4-mapped v6 is not a real v6 identity", []string{"::ffff:10.8.1.2/128"}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FirstIPv6(c.ips); got != c.want {
|
||||
t.Errorf("%s: FirstIPv6(%v) = %q, want %q", c.name, c.ips, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,566 +0,0 @@
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
|
||||
t.Helper()
|
||||
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
return string(bs)
|
||||
}
|
||||
|
||||
func validServer() *ServerSettings {
|
||||
return &ServerSettings{
|
||||
PrivateKey: "serverPriv",
|
||||
PublicKey: "serverPub",
|
||||
SubnetIP: "10.8.1.0",
|
||||
SubnetCIDR: 24,
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
||||
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
|
||||
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
|
||||
})
|
||||
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
|
||||
|
||||
inst, ok := InstanceFromInbound(ib)
|
||||
if !ok {
|
||||
t.Fatal("expected a usable instance")
|
||||
}
|
||||
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
|
||||
t.Fatalf("instance identity not carried over: %+v", inst)
|
||||
}
|
||||
if inst.InterfaceName != "awg7" {
|
||||
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
|
||||
}
|
||||
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
|
||||
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
|
||||
}
|
||||
if len(inst.Peers) != 1 {
|
||||
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
|
||||
}
|
||||
p := inst.Peers[0]
|
||||
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
|
||||
t.Fatalf("peer mismatch: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("non-AmneziaWG inbound must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsNil(t *testing.T) {
|
||||
if _, ok := InstanceFromInbound(nil); ok {
|
||||
t.Fatal("nil inbound must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("settings with no server block must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("unparseable settings must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
|
||||
settings := mkInboundSettings(t, validServer(), []model.Client{
|
||||
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
|
||||
if _, ok := InstanceFromInbound(ib); ok {
|
||||
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
subnet string
|
||||
cidr int
|
||||
want string
|
||||
}{
|
||||
{"10.8.1.0", 24, "10.8.1.1/24"},
|
||||
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
|
||||
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
|
||||
{"10.8.1.254", 24, "10.8.1.1/24"},
|
||||
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := serverAddress(c.subnet, c.cidr); got != c.want {
|
||||
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fixedObfuscation is a deterministic Obfuscation20 for tests that compare
|
||||
// two instances for equality — GenerateObfuscation20 is randomized per call
|
||||
// by design (see its doc comment) and must never be used where the test
|
||||
// expects two "identical" instances to actually match.
|
||||
func fixedObfuscation() Obfuscation20 {
|
||||
return Obfuscation20{Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90, S3: 20, S4: 10, H1: "10-2000", H2: "3000-5000", H3: "6000-8000", H4: "9000-11000", I1: "<r 64>"}
|
||||
}
|
||||
|
||||
func baseInstance() Instance {
|
||||
return Instance{
|
||||
Id: 1,
|
||||
Tag: "awg-1",
|
||||
InterfaceName: "awg1",
|
||||
ListenPort: 51820,
|
||||
PrivateKey: "priv",
|
||||
PublicKey: "pub",
|
||||
Address: []string{"10.8.1.1/24"},
|
||||
Obfuscation: fixedObfuscation(),
|
||||
Peers: []Peer{
|
||||
{Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
{Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
|
||||
a := baseInstance()
|
||||
b := baseInstance()
|
||||
if a.structuralFingerprint() != b.structuralFingerprint() {
|
||||
t.Fatal("identical instances must produce the same structural fingerprint")
|
||||
}
|
||||
b.ListenPort = 51821
|
||||
if a.structuralFingerprint() == b.structuralFingerprint() {
|
||||
t.Fatal("a listen port change must change the structural fingerprint")
|
||||
}
|
||||
c := baseInstance()
|
||||
c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"}
|
||||
if a.structuralFingerprint() != c.structuralFingerprint() {
|
||||
t.Fatal("a peer-only change must NOT change the structural fingerprint")
|
||||
}
|
||||
|
||||
d := baseInstance()
|
||||
d.IPv6Enabled = true
|
||||
if a.structuralFingerprint() == d.structuralFingerprint() {
|
||||
t.Fatal("enabling IPv6 must change the structural fingerprint")
|
||||
}
|
||||
|
||||
e := baseInstance()
|
||||
e.IPv6Enabled = true
|
||||
f := baseInstance()
|
||||
f.IPv6Enabled = true
|
||||
f.IPv6ExternalInterface = "eth1"
|
||||
if e.structuralFingerprint() == f.structuralFingerprint() {
|
||||
t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op")
|
||||
}
|
||||
|
||||
g := baseInstance()
|
||||
g.RouteThroughXray = true
|
||||
if a.structuralFingerprint() == g.structuralFingerprint() {
|
||||
t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
|
||||
a := baseInstance()
|
||||
reordered := baseInstance()
|
||||
reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0]
|
||||
if a.peersFingerprint() != reordered.peersFingerprint() {
|
||||
t.Fatal("reordering peers must not change the peers fingerprint")
|
||||
}
|
||||
|
||||
changed := baseInstance()
|
||||
changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
||||
if a.peersFingerprint() == changed.peersFingerprint() {
|
||||
t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint")
|
||||
}
|
||||
|
||||
fewer := baseInstance()
|
||||
fewer.Peers = fewer.Peers[:1]
|
||||
if a.peersFingerprint() == fewer.peersFingerprint() {
|
||||
t.Fatal("removing a peer must change the peers fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureActionFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
up bool
|
||||
curStruct, curHostRules, curPeers string
|
||||
newStruct, newHostRules, newPeers string
|
||||
want ensureAction
|
||||
}{
|
||||
{"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart},
|
||||
{"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart},
|
||||
{"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart},
|
||||
{"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart},
|
||||
{"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload},
|
||||
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers)
|
||||
if got != c.want {
|
||||
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTrafficBaseline(t *testing.T) {
|
||||
prev := map[string]peerCounters{"pubA": {rx: 100, tx: 200}}
|
||||
|
||||
if got := nextTrafficBaseline(ensureReload, prev); len(got) != 1 || got["pubA"] != prev["pubA"] {
|
||||
t.Errorf("a reload must preserve the previous baseline (syncconf never resets kernel counters), got %v", got)
|
||||
}
|
||||
if got := nextTrafficBaseline(ensureRestart, prev); len(got) != 0 {
|
||||
t.Errorf("a restart must reset the baseline to empty (awg-quick down+up zeroes kernel counters), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
|
||||
a := baseInstance()
|
||||
b := baseInstance()
|
||||
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
|
||||
t.Fatal("identical instances must produce the same host-rules fingerprint")
|
||||
}
|
||||
|
||||
forwarded := baseInstance()
|
||||
forwarded.Peers[0].ForwardedPorts = "80,443"
|
||||
if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() {
|
||||
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
|
||||
}
|
||||
|
||||
fewer := baseInstance()
|
||||
fewer.Peers = fewer.Peers[:1]
|
||||
if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() {
|
||||
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer")
|
||||
}
|
||||
|
||||
// RouteThroughXray off (baseInstance's default): no TPROXY rule depends
|
||||
// on a peer's IPv4 address, so re-IPing one must NOT force a bounce --
|
||||
// this is the whole point of making the bridge opt-in: an instance that
|
||||
// never uses it keeps the syncconf fast path for a plain re-IP.
|
||||
reIPedNoRoute := baseInstance()
|
||||
reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
||||
if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() {
|
||||
t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint")
|
||||
}
|
||||
|
||||
// A peer with forwarded ports has its DNAT rule keyed on its IPv4 address
|
||||
// too, regardless of RouteThroughXray -- re-IPing it must force a bounce,
|
||||
// or the old DNAT rule survives pointed at an address a different peer
|
||||
// can be handed next.
|
||||
forwardedReIPed := baseInstance()
|
||||
forwardedReIPed.Peers[0].ForwardedPorts = "80,443"
|
||||
forwardedBase := baseInstance()
|
||||
forwardedBase.Peers[0].ForwardedPorts = "80,443"
|
||||
forwardedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
||||
if forwardedBase.hostRulesFingerprint() == forwardedReIPed.hostRulesFingerprint() {
|
||||
t.Fatal("with RouteThroughXray off but ForwardedPorts set, changing a peer's IP must change the host-rules fingerprint -- its DNAT rule is keyed on that IP")
|
||||
}
|
||||
|
||||
// RouteThroughXray on: now the TPROXY rule really is keyed on the IP.
|
||||
routed := baseInstance()
|
||||
routed.RouteThroughXray = true
|
||||
routedReIPed := baseInstance()
|
||||
routedReIPed.RouteThroughXray = true
|
||||
routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
||||
if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() {
|
||||
t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
|
||||
}
|
||||
|
||||
// IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends
|
||||
// on a peer's IPv6 address either, so adding one must not force a bounce.
|
||||
ip6AddedNoIPv6 := baseInstance()
|
||||
ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
|
||||
if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() {
|
||||
t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint")
|
||||
}
|
||||
|
||||
ip6Base := baseInstance()
|
||||
ip6Base.IPv6Enabled = true
|
||||
ip6Added := baseInstance()
|
||||
ip6Added.IPv6Enabled = true
|
||||
ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
|
||||
if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() {
|
||||
t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEgressComment(t *testing.T) {
|
||||
if got := routeEgressComment(""); got != "awg-route" {
|
||||
t.Errorf("empty email must fall back to awg-route, got %q", got)
|
||||
}
|
||||
a := routeEgressComment("a@x")
|
||||
b := routeEgressComment("b@x")
|
||||
if a == b {
|
||||
t.Fatal("different emails must produce different comment tags")
|
||||
}
|
||||
if a != routeEgressComment("a@x") {
|
||||
t.Fatal("the same email must always produce the same comment tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEgressLines(t *testing.T) {
|
||||
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101)
|
||||
if len(up) != 2 {
|
||||
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
|
||||
}
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
found := false
|
||||
for _, l := range up {
|
||||
if !strings.Contains(l, "-p "+proto) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") ||
|
||||
!strings.Contains(l, "--on-port 63101") ||
|
||||
!strings.Contains(l, "--on-ip 127.0.0.1") ||
|
||||
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
|
||||
!strings.Contains(l, "-A PREROUTING") {
|
||||
t.Errorf("%s line missing expected fields: %s", proto, l)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing a %s TPROXY line in %v", proto, up)
|
||||
}
|
||||
}
|
||||
if strings.Contains(up[0], "10.8.1.2/32") {
|
||||
t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0])
|
||||
}
|
||||
|
||||
down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101)
|
||||
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
|
||||
t.Fatalf("expected symmetric -D lines, got %v", down)
|
||||
}
|
||||
|
||||
if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil {
|
||||
t.Errorf("empty clientIP must yield no lines, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressPortForInbound(t *testing.T) {
|
||||
if got := EgressPortForInbound(1); got != EgressBasePort+1 {
|
||||
t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1)
|
||||
}
|
||||
if EgressPortForInbound(1) == EgressPortForInbound(2) {
|
||||
t.Fatal("different inbound ids must derive different ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) {
|
||||
inst := baseInstance() // RouteThroughXray defaults to false
|
||||
up, down := defaultPostUpDown(inst, "eth0")
|
||||
|
||||
if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") {
|
||||
t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up)
|
||||
}
|
||||
if strings.Contains(down, "TPROXY") {
|
||||
t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) {
|
||||
inst := baseInstance() // two peers, a@x and b@x
|
||||
inst.RouteThroughXray = true
|
||||
up, down := defaultPostUpDown(inst, "eth0")
|
||||
|
||||
wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id))
|
||||
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) {
|
||||
t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up)
|
||||
}
|
||||
if !strings.Contains(down, "TPROXY") {
|
||||
t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down)
|
||||
}
|
||||
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
|
||||
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
|
||||
}
|
||||
// grep -c, not -q: -q's early exit can SIGPIPE "ip rule list" and, under
|
||||
// pipefail, make the existence check itself report failure even when the
|
||||
// rule was found -- which would re-run "ip rule add" and reintroduce the
|
||||
// exact duplicate this check exists to prevent.
|
||||
if wantCheck := fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) {
|
||||
t.Errorf("expected a pipefail-safe existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up)
|
||||
}
|
||||
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
|
||||
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
|
||||
}
|
||||
// Both peers get TPROXY'd once opted in: 2 peers * 2 protocols.
|
||||
if got := strings.Count(up, "TPROXY"); got != 4 {
|
||||
t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up)
|
||||
}
|
||||
|
||||
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
|
||||
upNone, _ := defaultPostUpDown(none, "eth0")
|
||||
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
|
||||
t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
|
||||
}
|
||||
}
|
||||
|
||||
// TPROXY never rewrites a packet's own destination address -- only the
|
||||
// routing decision changes -- so a default-deny INPUT chain that sanity-checks
|
||||
// "is this destination actually local" (e.g. UFW's ufw-not-local, via
|
||||
// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even
|
||||
// though TPROXY's own mangle-table counters keep incrementing the whole time.
|
||||
// This was a real, hard-to-diagnose production outage: RouteThroughXray
|
||||
// looked fully configured (TPROXY rule present, Xray socket listening with
|
||||
// IP_TRANSPARENT set) yet every peer's traffic silently vanished.
|
||||
func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) {
|
||||
inst := baseInstance() // two peers, a@x and b@x
|
||||
inst.RouteThroughXray = true
|
||||
up, down := defaultPostUpDown(inst, "eth0")
|
||||
|
||||
wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark)
|
||||
wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark)
|
||||
if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) {
|
||||
t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up)
|
||||
}
|
||||
if strings.Contains(down, "-m mark --mark") {
|
||||
t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route")
|
||||
}
|
||||
|
||||
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
|
||||
upNone, _ := defaultPostUpDown(none, "eth0")
|
||||
if strings.Contains(upNone, "-m mark --mark") {
|
||||
t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone)
|
||||
}
|
||||
}
|
||||
|
||||
// PostDown is joined with "; " and run under `set -e`, so one command that
|
||||
// fails because something already flushed the firewall state out from under
|
||||
// the interface (a ufw/firewalld reload) would otherwise abort every command
|
||||
// after it -- including the nat-table DNAT deletes a filter-table flush does
|
||||
// NOT remove, which then survive and accumulate across bounces. Every
|
||||
// teardown command must be best-effort; PostUp must not be.
|
||||
func TestDefaultPostUpDownMakesEveryTeardownCommandBestEffort(t *testing.T) {
|
||||
inst := baseInstance() // two peers, a@x and b@x
|
||||
inst.RouteThroughXray = true
|
||||
inst.IPv6Enabled = true
|
||||
inst.Peers[0].ForwardedPorts = "80,443"
|
||||
up, down := defaultPostUpDown(inst, "eth0")
|
||||
|
||||
for _, cmd := range strings.Split(down, "; ") {
|
||||
if !strings.HasSuffix(cmd, "|| true") {
|
||||
t.Errorf("every PostDown command must end with '|| true' so a flushed firewall doesn't abort the rest, got: %q", cmd)
|
||||
}
|
||||
}
|
||||
if strings.Contains(up, "|| true") {
|
||||
t.Error("PostUp must stay strict -- a real setup failure there should surface, not be silently swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
|
||||
inst := baseInstance()
|
||||
inst.ExternalInterface = "eth0"
|
||||
cfg := generateServerConfig(inst)
|
||||
|
||||
want := []string{
|
||||
"[Interface]",
|
||||
"PrivateKey = priv",
|
||||
"Address = 10.8.1.1/24",
|
||||
"ListenPort = 51820",
|
||||
"[Peer]",
|
||||
"PublicKey = pubA",
|
||||
"PresharedKey = pskA",
|
||||
"AllowedIPs = 10.8.1.2/32",
|
||||
"PublicKey = pubB",
|
||||
"AllowedIPs = 10.8.1.3/32",
|
||||
"MASQUERADE",
|
||||
}
|
||||
for _, w := range want {
|
||||
if !strings.Contains(cfg, w) {
|
||||
t.Errorf("generated config missing %q\n---\n%s", w, cfg)
|
||||
}
|
||||
}
|
||||
// The second peer has no PresharedKey — its block must not emit the field at all.
|
||||
if strings.Count(cfg, "PresharedKey") != 1 {
|
||||
t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteObfuscationDefaultsBlankH(t *testing.T) {
|
||||
var b strings.Builder
|
||||
writeObfuscation(&b, Obfuscation20{})
|
||||
out := b.String()
|
||||
for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out)
|
||||
}
|
||||
}
|
||||
// S3/S4/I1 are zero-valued here and must be omitted entirely.
|
||||
if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") {
|
||||
t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameForID(t *testing.T) {
|
||||
if got := interfaceNameForID(42); got != "awg42" {
|
||||
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundIDForInterfaceName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wantID int
|
||||
wantOK bool
|
||||
}{
|
||||
{"awg42", 42, true},
|
||||
{"awg0", 0, true},
|
||||
{"awg", 0, false}, // no digits after the prefix
|
||||
{"wg0", 0, false}, // wrong prefix entirely (plain WireGuard)
|
||||
{"awgabc", 0, false}, // non-numeric suffix
|
||||
{"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway
|
||||
}
|
||||
for _, c := range cases {
|
||||
id, ok := inboundIDForInterfaceName(c.name)
|
||||
if ok != c.wantOK || (ok && id != c.wantID) {
|
||||
t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrphanedInterfaces(t *testing.T) {
|
||||
confFiles := []string{
|
||||
"awg1.conf", // in want -> not orphaned
|
||||
"awg2.conf", // not in want -> orphaned
|
||||
"awg3.conf", // not in want -> orphaned
|
||||
"notes.txt", // wrong suffix -> ignored
|
||||
"awgxyz.conf", // unparseable id -> ignored
|
||||
}
|
||||
want := map[int]struct{}{1: {}}
|
||||
|
||||
got := orphanedInterfaces(confFiles, want)
|
||||
slices.Sort(got)
|
||||
if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) {
|
||||
t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) {
|
||||
got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{})
|
||||
if want := []string{"awg5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("orphanedInterfaces() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -148,13 +148,14 @@ var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
|
||||
|
||||
// ValidateInterfaceName rejects a value that isn't a plausible network
|
||||
// interface name before it's saved. ExternalInterface and
|
||||
// IPv6ExternalInterface are interpolated unescaped into a shell-executed
|
||||
// PostUp/PostDown line by generateServerConfig, so — unlike the client email
|
||||
// (already hashed for exactly this reason, see routeEgressComment) — an
|
||||
// unvalidated value here could carry a shell metacharacter straight into a
|
||||
// root-executed command. A blank value is allowed: it means "auto-detect"
|
||||
// for ExternalInterface, or "reuse ExternalInterface" for
|
||||
// IPv6ExternalInterface.
|
||||
// IPv6ExternalInterface are vestigial as of the hard cutover to the
|
||||
// embedded path (see types.go's ServerSettings), but this validation stays:
|
||||
// Phase 3.5's planned real-IPv6-address-alias mechanism will shell out to
|
||||
// `ip -6 addr add ... dev <ext6>`, and an unvalidated value here could carry
|
||||
// a shell metacharacter straight into that root-executed command, the same
|
||||
// risk the retired kernel-module PostUp/PostDown generator had. A blank
|
||||
// value is allowed: it means "auto-detect" for ExternalInterface, or "reuse
|
||||
// ExternalInterface" for IPv6ExternalInterface.
|
||||
func ValidateInterfaceName(name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
|
||||
@@ -233,12 +233,3 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeConfigValueStripsControlCharactersOnly(t *testing.T) {
|
||||
if got := sanitizeConfigValue("a@x\nPostUp = evil\r\n"); got != "a@xPostUp = evil" {
|
||||
t.Errorf("sanitizeConfigValue must drop newlines/CR without altering the rest, got %q", got)
|
||||
}
|
||||
if got := sanitizeConfigValue("plain-value_123"); got != "plain-value_123" {
|
||||
t.Errorf("sanitizeConfigValue must not touch an already-clean value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package amneziawg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -13,31 +12,10 @@ type portSpec struct {
|
||||
end int
|
||||
}
|
||||
|
||||
func (p portSpec) isRange() bool { return p.end > p.start }
|
||||
|
||||
// dportArg returns the iptables --dport argument: "N" or "N:M".
|
||||
func (p portSpec) dportArg() string {
|
||||
if p.isRange() {
|
||||
return fmt.Sprintf("%d:%d", p.start, p.end)
|
||||
}
|
||||
return strconv.Itoa(p.start)
|
||||
}
|
||||
|
||||
// dnatTarget returns the DNAT target: "ip:N" or "ip:N-M".
|
||||
func (p portSpec) dnatTarget(clientIP string) string {
|
||||
if p.isRange() {
|
||||
return fmt.Sprintf("%s:%d-%d", clientIP, p.start, p.end)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", clientIP, p.start)
|
||||
}
|
||||
|
||||
// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100")
|
||||
// into validated port specs. Tokens are separated by comma or semicolon;
|
||||
// whitespace is ignored. Invalid tokens are silently dropped — the input is
|
||||
// a free-form text field and validation is best-effort by design. Every
|
||||
// returned spec's bounds are integers in [1, 65535], so callers can safely
|
||||
// embed them in a shell-executed PostUp/PostDown line without further
|
||||
// escaping.
|
||||
// a free-form text field and validation is best-effort by design.
|
||||
func parseForwardedPorts(input string) []portSpec {
|
||||
if input == "" {
|
||||
return nil
|
||||
@@ -91,13 +69,20 @@ func parsePortNumber(s string) (int, bool) {
|
||||
}
|
||||
|
||||
// ForwardedPortsInclude reports whether port is covered by any spec in a raw
|
||||
// ForwardedPorts string (a single port or an inclusive range). For callers
|
||||
// outside this package that need to check a spec against something other
|
||||
// than rendering it into iptables rules -- e.g. save-time validation that a
|
||||
// client isn't about to hijack the panel's own port or another inbound's
|
||||
// port (portForwardLines has no -d restriction, so a forwarded port that
|
||||
// collides with one already in use on the host silently redirects it to the
|
||||
// tunnel client instead).
|
||||
// ForwardedPorts string (a single port or an inclusive range). Used for
|
||||
// save-time validation that a client isn't about to hijack the panel's own
|
||||
// port or another inbound's port -- see
|
||||
// internal/web/service/inbound_amneziawg.go's port-conflict checks.
|
||||
//
|
||||
// The field itself is currently inert: per-client port-forwarding was
|
||||
// implemented via PostUp/PostDown iptables DNAT rules under the retired
|
||||
// kernel-module architecture (internal/amneziawg's old Manager), which had
|
||||
// no equivalent under the embedded amneziawg-go path
|
||||
// (internal/amneziawgnet) as of the hard cutover -- see the migration
|
||||
// plan's Phase 3.6 for the panel-side relay design that will restore it.
|
||||
// The field and this validation are kept so existing values aren't lost and
|
||||
// re-validated identically once that phase lands, not because anything
|
||||
// currently acts on them.
|
||||
func ForwardedPortsInclude(forwardedPorts string, port int) bool {
|
||||
for _, spec := range parseForwardedPorts(forwardedPorts) {
|
||||
if port >= spec.start && port <= spec.end {
|
||||
@@ -106,62 +91,3 @@ func ForwardedPortsInclude(forwardedPorts string, port int) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// portForwardComment returns a short, shell-safe iptables comment tag for one
|
||||
// peer's forwarded-port rules, so PostDown removes exactly what PostUp added
|
||||
// regardless of ordering. Derived from a hash of the peer's email rather than
|
||||
// the email itself: email is admin/API-supplied free text that ends up
|
||||
// embedded in a shell-executed PostUp/PostDown line, and a hash can never
|
||||
// carry a shell metacharacter through.
|
||||
func portForwardComment(email string) string {
|
||||
if email == "" {
|
||||
return "awg-fwd"
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(email))
|
||||
return fmt.Sprintf("awg-fwd-%08x", h.Sum32())
|
||||
}
|
||||
|
||||
// portForwardLines returns the PostUp ("-A") or PostDown ("-D") iptables
|
||||
// lines for one peer's forwarded-ports spec: a DNAT rule (tcp and udp) per
|
||||
// spec in the nat table, plus a matching FORWARD accept rule. UDP is
|
||||
// included unconditionally since many common uses (games, P2P) need it.
|
||||
// Returns nil when forwardedPorts has no valid spec or clientIP is empty.
|
||||
func portForwardLines(action, extIface, tunIface, clientIP, email, forwardedPorts string) []string {
|
||||
specs := parseForwardedPorts(forwardedPorts)
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
clientIP = stripCIDRMask(clientIP)
|
||||
if clientIP == "" {
|
||||
return nil
|
||||
}
|
||||
comment := portForwardComment(email)
|
||||
|
||||
lines := make([]string, 0, len(specs)*4)
|
||||
for _, spec := range specs {
|
||||
dport := spec.dportArg()
|
||||
target := spec.dnatTarget(clientIP)
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
nat := fmt.Sprintf("iptables -t nat %s PREROUTING -p %s", action, proto)
|
||||
if extIface != "" {
|
||||
nat += fmt.Sprintf(" -i %s", extIface)
|
||||
}
|
||||
nat += fmt.Sprintf(" --dport %s -m comment --comment %s -j DNAT --to-destination %s", dport, comment, target)
|
||||
lines = append(lines, nat)
|
||||
|
||||
fwd := fmt.Sprintf("iptables %s FORWARD -d %s -p %s -o %s --dport %s -m comment --comment %s -j ACCEPT",
|
||||
action, clientIP, proto, tunIface, dport, comment)
|
||||
lines = append(lines, fwd)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// stripCIDRMask removes a "/N" suffix if present.
|
||||
func stripCIDRMask(addr string) string {
|
||||
if idx := strings.IndexByte(addr, '/'); idx >= 0 {
|
||||
return addr[:idx]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
)
|
||||
|
||||
// EgressBasePort is the first loopback port used for an AmneziaWG inbound's
|
||||
// own Xray TPROXY bridge. The bridge is opt-in per inbound, gated on
|
||||
// Instance.RouteThroughXray (off by default): only when it's on does
|
||||
// defaultPostUpDown's TPROXY rules redirect a peer's traffic there, and only
|
||||
// then does internal/web/service's injectAmneziawgEgress create the matching
|
||||
// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so
|
||||
// it's already selectable in the panel's stock Routing page (the same
|
||||
// mechanism that already makes an mtproto inbound's own bridge routable
|
||||
// there — see injectMtprotoEgress). A plain AmneziaWG tunnel with routing
|
||||
// left off never depends on Xray being up at all. Whether — and where —
|
||||
// routed traffic actually goes anywhere beyond Xray's default routing is
|
||||
// entirely up to whatever rules the admin adds on that page; this package
|
||||
// and injectAmneziawgEgress never generate a routing rule themselves.
|
||||
//
|
||||
// EgressPortForInbound derives each inbound's own port deterministically
|
||||
// from its id, so the two independent reconcile loops (this package's
|
||||
// PostUp generator and the Xray-config generator, in a different package)
|
||||
// never have to agree on a runtime-negotiated value.
|
||||
const EgressBasePort = 63100
|
||||
|
||||
// EgressPortForInbound returns the loopback port of one AmneziaWG inbound's
|
||||
// own Xray TPROXY bridge.
|
||||
func EgressPortForInbound(inboundID int) int {
|
||||
return EgressBasePort + inboundID
|
||||
}
|
||||
|
||||
// EgressFwmark and EgressTable are the fwmark and policy-routing table
|
||||
// TPROXY needs to deliver a peer's packets to a local socket even though
|
||||
// their destination is never one of this host's own addresses. Shared by
|
||||
// every AmneziaWG instance's bridge — only the port differs per instance.
|
||||
// Chosen to be distinctive; if either happens to collide with something else
|
||||
// already using fwmarks/routing tables on the host, change the values here —
|
||||
// nothing outside this package and its own PostUp/PostDown output depends on
|
||||
// the actual numbers.
|
||||
const (
|
||||
EgressFwmark = 0x2377
|
||||
EgressTable = 87
|
||||
)
|
||||
|
||||
// routeEgressComment returns a short, shell-safe iptables comment tag for one
|
||||
// peer's TPROXY rule, so PostDown removes exactly what PostUp added
|
||||
// regardless of ordering. Derived from a hash of the peer's email for the
|
||||
// same reason portForwardComment is: email is admin/API-supplied free text
|
||||
// that ends up embedded in a shell-executed PostUp/PostDown line, and a hash
|
||||
// can never carry a shell metacharacter through.
|
||||
func routeEgressComment(email string) string {
|
||||
if email == "" {
|
||||
return "awg-route"
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(email))
|
||||
return fmt.Sprintf("awg-route-%08x", h.Sum32())
|
||||
}
|
||||
|
||||
// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table
|
||||
// TPROXY lines that redirect one peer's traffic — matched by its tunnel
|
||||
// source IP, arriving on tunIface — into that instance's own Xray bridge on
|
||||
// port. Both TCP and UDP are covered since every peer's whole traffic is
|
||||
// meant to reach the bridge, not just a specific protocol or port; which
|
||||
// outbound (if any) it then takes is entirely up to the admin's own Routing
|
||||
// rules. Returns nil when clientIP is empty.
|
||||
func routeEgressLines(action, tunIface, clientIP, email string, port int) []string {
|
||||
clientIP = stripCIDRMask(clientIP)
|
||||
if clientIP == "" {
|
||||
return nil
|
||||
}
|
||||
comment := routeEgressComment(email)
|
||||
lines := make([]string, 0, 2)
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x",
|
||||
action, tunIface, clientIP, proto, comment, port, EgressFwmark, EgressFwmark,
|
||||
))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
+33
-26
@@ -58,27 +58,35 @@ type Instance struct {
|
||||
Obfuscation Obfuscation20
|
||||
Peers []Peer
|
||||
|
||||
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
|
||||
// Empty means auto-detect at config-generation time.
|
||||
// ExternalInterface named the host NIC PostUp/PostDown NAT rules
|
||||
// attached to under the retired kernel-module architecture. Also the
|
||||
// fallback host NIC internal/amneziawgnet's IPv6-address-alias
|
||||
// mechanism (desiredV6Aliases) uses when IPv6ExternalInterface is left
|
||||
// blank.
|
||||
ExternalInterface string
|
||||
|
||||
// IPv6Enabled turns on the per-peer NDP proxy PostUp/PostDown entries
|
||||
// (ip -6 neigh add/del proxy) for peers that have an IPv6 AllowedIPs
|
||||
// entry. IPv6ExternalInterface overrides ExternalInterface for those
|
||||
// entries specifically; empty means reuse ExternalInterface.
|
||||
// IPv6Enabled/IPv6ExternalInterface gate internal/amneziawgnet's
|
||||
// IPv6-address-alias mechanism (desiredV6Aliases,
|
||||
// internal/web/service/xray.go's injectAmneziawgV6Egress): each peer
|
||||
// with an IPv6 AllowedIPs entry gets that address aliased onto this
|
||||
// host NIC (ip -6 addr add) and a dedicated Xray freedom outbound bound
|
||||
// to it, giving that peer's own outbound connections a distinct public
|
||||
// source identity. Narrower in scope than these identically-named
|
||||
// fields' role under the retired kernel-module architecture, which used
|
||||
// per-peer NDP-proxy entries (ip -6 neigh add proxy) to also support
|
||||
// unsolicited inbound connections toward the peer -- that capability is
|
||||
// the separate, not-yet-built Phase 3.6 (port-forwarding).
|
||||
IPv6Enabled bool
|
||||
IPv6ExternalInterface string
|
||||
|
||||
// RouteThroughXray gates the entire TPROXY-into-Xray bridge (see
|
||||
// EgressPortForInbound / injectAmneziawgEgress) for this instance: off by
|
||||
// default, so a plain AmneziaWG tunnel never depends on Xray being up at
|
||||
// all. Turning it on makes every peer's traffic TPROXY'd into this
|
||||
// instance's own loopback Xray bridge, tagged with the inbound's own
|
||||
// tag; the actual routing decision from there is left entirely to the
|
||||
// panel's stock Routing page (pick this inbound's tag as source, an
|
||||
// outbound, and optionally a peer's IP), exactly like routing any other
|
||||
// protocol -- only whether the bridge exists at all is a per-inbound
|
||||
// choice.
|
||||
// RouteThroughXray gated the kernel-module architecture's opt-in
|
||||
// TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet)
|
||||
// has no equivalent opt-in at all -- every peer's traffic already goes
|
||||
// through Xray's own SOCKS5 inbound unconditionally, since there's no
|
||||
// other way for decapsulated gVisor traffic to reach the real internet
|
||||
// -- so this field is now vestigial: read from existing stored settings
|
||||
// for backward compatibility, but not acted on by anything. Slated for
|
||||
// removal alongside the frontend toggle in a follow-up.
|
||||
RouteThroughXray bool
|
||||
}
|
||||
|
||||
@@ -99,22 +107,21 @@ type ServerSettings struct {
|
||||
PrimaryDNS string `json:"primaryDns,omitempty"`
|
||||
SecondaryDNS string `json:"secondaryDns,omitempty"`
|
||||
|
||||
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
|
||||
// Empty means auto-detect.
|
||||
// ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live
|
||||
// again as of Phase 3.5 -- see the matching fields on Instance for what
|
||||
// they gate (internal/amneziawgnet's IPv6-address-alias mechanism).
|
||||
// IPv6Subnet was never actually vestigial either: InstanceFromInbound
|
||||
// already consumes it (via serverAddressV6) to build the server's own
|
||||
// tunnel address, same as always. Only RouteThroughXray, below, remains
|
||||
// genuinely vestigial as of the hard cutover to the embedded path
|
||||
// (internal/amneziawgnet) -- read from existing stored settings for
|
||||
// backward compatibility, but not acted on by anything.
|
||||
ExternalInterface string `json:"externalInterface,omitempty"`
|
||||
|
||||
// IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is
|
||||
// allocated from IPv6Subnet alongside each client's IPv4 one, and the
|
||||
// server proxies NDP for each enabled client's address so upstream
|
||||
// routers see it as directly reachable (no NAT66). IPv6ExternalInterface
|
||||
// overrides ExternalInterface for the NDP-proxy PostUp/PostDown entries
|
||||
// specifically; empty reuses ExternalInterface.
|
||||
IPv6Enabled bool `json:"ipv6Enabled,omitempty"`
|
||||
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
|
||||
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
|
||||
|
||||
// RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see
|
||||
// Instance.RouteThroughXray for what that means. Off by default.
|
||||
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
|
||||
|
||||
// Obfuscation20's fields, repeated flat (not embedded) rather than
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// defaultMTU matches internal/amneziawg's own kernel-module interface
|
||||
// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
|
||||
// encapsulation overhead on a standard 1500-byte-MTU host link.
|
||||
const defaultMTU = 1420
|
||||
|
||||
// DeviceOptions carries the AmneziaWG 3.0 header-protection fields, kept out
|
||||
// of amneziawg.Instance/Obfuscation20 deliberately: those are the shared,
|
||||
// DB-backed types the still-live kernel-module path also reads and writes,
|
||||
// and 3.0 header protection is a device-wide, strictly opt-in setting (see
|
||||
// the migration plan's "Reference material" section) that isn't wired into
|
||||
// that shared schema yet. Zero-value DeviceOptions means classic
|
||||
// (non-3.0) obfuscation only, matching the kernel-module path's own
|
||||
// defaults today.
|
||||
type DeviceOptions struct {
|
||||
// HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0
|
||||
// header protection entirely. Non-empty requires every one of
|
||||
// Obfuscation20.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize
|
||||
// requirement) -- IpcSet will reject the config otherwise.
|
||||
HeaderProtectionKey string
|
||||
// ContentPaddingAddition is a "low-high" range (or a bare integer) per
|
||||
// amneziawg-tools' own u16_range_from_string grammar. Empty disables it.
|
||||
ContentPaddingAddition string
|
||||
// Logger is passed to device.NewDevice as-is; nil uses a silent logger
|
||||
// (device.NewLogger(device.LogLevelSilent, "")).
|
||||
Logger *device.Logger
|
||||
}
|
||||
|
||||
// Device is one running embedded AmneziaWG interface: an amneziawg-go
|
||||
// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to
|
||||
// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears
|
||||
// down both the WireGuard device and the underlying tun/stack.
|
||||
type Device struct {
|
||||
*device.Device
|
||||
Stack *stack.Stack
|
||||
}
|
||||
|
||||
// NewDevice constructs and brings up an embedded AmneziaWG interface for
|
||||
// inst: a gVisor-backed tun.Device sized to inst.MTU (or defaultMTU),
|
||||
// addressed with inst.Address, configured via UAPI with inst.Obfuscation,
|
||||
// inst.PrivateKey, opts' AWG 3.0 fields, and one UAPI peer per inst.Peers
|
||||
// entry. It does not attach a forwarder or start relaying traffic --
|
||||
// that's the caller's job (see AttachTCPForwarder / AttachUDPHandler),
|
||||
// keeping this constructor usable both for a real relay and for a plain
|
||||
// mechanical test.
|
||||
func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
|
||||
addrs, err := hostAddresses(inst.Address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amneziawgnet: %w", err)
|
||||
}
|
||||
|
||||
mtu := inst.MTU
|
||||
if mtu <= 0 {
|
||||
mtu = defaultMTU
|
||||
}
|
||||
|
||||
tun, gstack, err := createNetTUNWithStack(addrs, mtu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
|
||||
}
|
||||
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = device.NewLogger(device.LogLevelSilent, "")
|
||||
}
|
||||
dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
|
||||
|
||||
conf, err := buildUAPIConfig(inst, opts)
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("amneziawgnet: %w", err)
|
||||
}
|
||||
if err := dev.IpcSet(conf); err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err)
|
||||
}
|
||||
if err := dev.Up(); err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err)
|
||||
}
|
||||
|
||||
return &Device{Device: dev, Stack: gstack}, nil
|
||||
}
|
||||
|
||||
// hostAddresses parses each of inst.Address's CIDR strings (e.g.
|
||||
// "10.8.1.1/24") down to the bare host address the netstack's NIC gets
|
||||
// configured with -- the interface's own address, not the subnet it routes.
|
||||
func hostAddresses(addresses []string) ([]netip.Addr, error) {
|
||||
out := make([]netip.Addr, 0, len(addresses))
|
||||
for _, a := range addresses {
|
||||
prefix, err := netip.ParsePrefix(a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid interface address %q: %w", a, err)
|
||||
}
|
||||
out = append(out, prefix.Addr())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard
|
||||
// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/
|
||||
// h1-h4/i1 device lines, the AWG 3.0 device lines when opts asks for them,
|
||||
// then one public_key/preshared_key/allowed_ip block per peer. Field names
|
||||
// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed
|
||||
// against its real source during Phase 0 spiking, not just its docs).
|
||||
func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) {
|
||||
var b strings.Builder
|
||||
|
||||
privHex, err := wireguard.KeyToHex(inst.PrivateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid server private key: %w", err)
|
||||
}
|
||||
fmt.Fprintf(&b, "private_key=%s\n", privHex)
|
||||
fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
|
||||
// replace_peers makes every apply a full resync (matches this package's
|
||||
// own Manager.Ensure semantics): peers no longer in inst.Peers are
|
||||
// dropped instead of lingering from a previous IpcSet call.
|
||||
b.WriteString("replace_peers=true\n")
|
||||
|
||||
o := inst.Obfuscation
|
||||
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
|
||||
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
|
||||
writeHLine(&b, "h1", o.H1)
|
||||
writeHLine(&b, "h2", o.H2)
|
||||
writeHLine(&b, "h3", o.H3)
|
||||
writeHLine(&b, "h4", o.H4)
|
||||
if o.I1 != "" {
|
||||
fmt.Fprintf(&b, "i1=%s\n", o.I1)
|
||||
}
|
||||
|
||||
if opts.HeaderProtectionKey != "" {
|
||||
hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid header protection key: %w", err)
|
||||
}
|
||||
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
|
||||
}
|
||||
if opts.ContentPaddingAddition != "" {
|
||||
fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
|
||||
}
|
||||
|
||||
for _, p := range inst.Peers {
|
||||
pubHex, err := wireguard.KeyToHex(p.PublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "public_key=%s\n", pubHex)
|
||||
if p.PresharedKey != "" {
|
||||
pskHex, err := wireguard.KeyToHex(p.PresharedKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
|
||||
}
|
||||
for _, allowedIP := range p.AllowedIPs {
|
||||
fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// writeHLine writes an hN UAPI line only when v is set -- an empty H value
|
||||
// means "let amneziawg-go fall back to its own default," mirroring how
|
||||
// internal/amneziawg's generateServerConfig treats the same optional field.
|
||||
func writeHLine(b *strings.Builder, name, v string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s=%s\n", name, v)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
|
||||
// proof, not just a compile check: a genuine amneziawg-go client (via that
|
||||
// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
|
||||
// forwarder or peer-identity resolution, only this package's server side
|
||||
// does) completes a real 3-way handshake against a Device built by
|
||||
// NewDevice, dials a destination that was never configured anywhere on the
|
||||
// server, and the test verifies AttachTCPForwarder recovers that exact
|
||||
// destination *and* PeerIndex.Lookup resolves the connection's source back
|
||||
// to the right peer's Email -- Phase 1a/1b/1c working together, the same
|
||||
// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
|
||||
// repeatable test instead of scratch code.
|
||||
func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
|
||||
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate server keypair: %v", err)
|
||||
}
|
||||
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate client keypair: %v", err)
|
||||
}
|
||||
|
||||
const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
|
||||
const wantEmail = "test-peer@example.com"
|
||||
|
||||
inst := amneziawg.Instance{
|
||||
Id: 1,
|
||||
InterfaceName: "awgtest1",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{"10.201.0.1/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: amneziawg.Obfuscation20{
|
||||
Jc: 4, Jmin: 40, Jmax: 70,
|
||||
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||
},
|
||||
Peers: []amneziawg.Peer{{
|
||||
Email: wantEmail,
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{"10.201.0.2/32"},
|
||||
}},
|
||||
}
|
||||
|
||||
dev, err := NewDevice(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer dev.Close()
|
||||
|
||||
idx := NewPeerIndex(inst.Peers)
|
||||
|
||||
type recovered struct {
|
||||
email string
|
||||
ok bool
|
||||
dest netip.AddrPort
|
||||
}
|
||||
got := make(chan recovered, 1)
|
||||
|
||||
// Never configured anywhere server-side: the forwarder must recover it
|
||||
// purely from the decapsulated packet, not from any routing table.
|
||||
wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
|
||||
|
||||
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
|
||||
defer conn.Close()
|
||||
srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
|
||||
var peer amneziawg.Peer
|
||||
var ok bool
|
||||
if parseErr == nil {
|
||||
peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
|
||||
}
|
||||
got <- recovered{email: peer.Email, ok: ok, dest: dest}
|
||||
io.Copy(io.Discard, conn)
|
||||
})
|
||||
|
||||
clientTun, clientNet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("10.201.0.2")},
|
||||
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
|
||||
if err != nil {
|
||||
t.Fatalf("client CreateNetTUN: %v", err)
|
||||
}
|
||||
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||
defer clientDev.Close()
|
||||
|
||||
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("client key to hex: %v", err)
|
||||
}
|
||||
serverPubHex, err := wireguard.KeyToHex(serverPub)
|
||||
if err != nil {
|
||||
t.Fatalf("server key to hex: %v", err)
|
||||
}
|
||||
// allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
|
||||
// config (route everything through the tunnel) -- it's also what makes
|
||||
// dialing an arbitrary, never-configured destination like wantDest
|
||||
// actually get routed to the server peer at all: a narrower AllowedIPs
|
||||
// here would make the client's own Device drop the packet as
|
||||
// non-matching before it ever reached the wire.
|
||||
clientConf := fmt.Sprintf(
|
||||
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
|
||||
clientPrivHex, serverPubHex, listenPort)
|
||||
if err := clientDev.IpcSet(clientConf); err != nil {
|
||||
t.Fatalf("client IpcSet: %v", err)
|
||||
}
|
||||
if err := clientDev.Up(); err != nil {
|
||||
t.Fatalf("client Up: %v", err)
|
||||
}
|
||||
|
||||
// Retry the dial rather than guessing a fixed handshake delay: the
|
||||
// first attempts may race the handshake, later ones should succeed
|
||||
// once it completes.
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var lastErr error
|
||||
for {
|
||||
conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
|
||||
if dialErr == nil {
|
||||
conn.Close()
|
||||
break
|
||||
}
|
||||
lastErr = dialErr
|
||||
select {
|
||||
case <-dialCtx.Done():
|
||||
t.Fatalf("client dial never succeeded: %v", lastErr)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-got:
|
||||
if !r.ok {
|
||||
t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
|
||||
}
|
||||
if r.email != wantEmail {
|
||||
t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
|
||||
}
|
||||
if r.dest != wantDest {
|
||||
t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
||||
"gvisor.dev/gvisor/pkg/waiter"
|
||||
)
|
||||
|
||||
// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
|
||||
// spoofing mode, so it accepts connections addressed to any destination --
|
||||
// not just the stack's own configured local address -- and hands the
|
||||
// handler both the accepted connection and the tunnel client's real,
|
||||
// dynamically-arbitrary destination (recovered from the connection's own
|
||||
// TransportEndpointID, not from any preconfigured routing table). This is
|
||||
// the mechanism the whole embedded-AmneziaWG design depends on: what the
|
||||
// handler does with that destination (dial it directly, relay it into
|
||||
// Xray's SOCKS5 inbound, ...) is entirely up to the caller.
|
||||
//
|
||||
// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT).
|
||||
func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) {
|
||||
enablePromiscuousRouting(gstack)
|
||||
|
||||
fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) {
|
||||
go func(r *tcp.ForwarderRequest) {
|
||||
var wq waiter.Queue
|
||||
id := r.ID()
|
||||
|
||||
ep, err := r.CreateEndpoint(&wq)
|
||||
if err != nil {
|
||||
r.Complete(true)
|
||||
return
|
||||
}
|
||||
dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
|
||||
handler(gonet.NewTCPConn(&wq, ep), dest)
|
||||
ep.Close()
|
||||
r.Complete(false)
|
||||
}(r)
|
||||
})
|
||||
gstack.SetTransportProtocolHandler(tcp.ProtocolNumber, fwd.HandlePacket)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
)
|
||||
|
||||
// PeerIndex resolves a decapsulated connection's tunnel-internal source
|
||||
// address back to the peer it belongs to, the same role Xray-core's own
|
||||
// wireguard proxy's GetUserByAddr plays -- sourced here from an
|
||||
// amneziawg.Instance's own Peers (already carries Email per peer, no new
|
||||
// data needed) rather than a separate user table.
|
||||
type PeerIndex struct {
|
||||
entries []peerIndexEntry
|
||||
}
|
||||
|
||||
type peerIndexEntry struct {
|
||||
prefix netip.Prefix
|
||||
peer amneziawg.Peer
|
||||
}
|
||||
|
||||
// NewPeerIndex builds a lookup index from peers' AllowedIPs. Entries with an
|
||||
// unparseable AllowedIPs value are skipped rather than failing the whole
|
||||
// index -- by the time an Instance reaches this package, AllowedIPs has
|
||||
// already been accepted at save time (see internal/amneziawg's own
|
||||
// validation), so a bad entry here would only mean stale/manually-edited
|
||||
// data, not something worth refusing to serve the rest of the peers over.
|
||||
func NewPeerIndex(peers []amneziawg.Peer) *PeerIndex {
|
||||
idx := &PeerIndex{}
|
||||
for _, p := range peers {
|
||||
for _, a := range p.AllowedIPs {
|
||||
prefix, err := netip.ParsePrefix(a)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
idx.entries = append(idx.entries, peerIndexEntry{prefix: prefix, peer: p})
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// Lookup returns the peer whose AllowedIPs most specifically contains addr --
|
||||
// the same longest-prefix-match rule a real AmneziaWG interface's own
|
||||
// AllowedIPs routing table uses for outbound packets, applied here in
|
||||
// reverse to attribute an inbound (tunnel-internal-source) packet back to
|
||||
// its owning peer.
|
||||
func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
|
||||
bestBits := -1
|
||||
var bestPeer amneziawg.Peer
|
||||
for _, e := range idx.entries {
|
||||
if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
bestBits = e.prefix.Bits()
|
||||
bestPeer = e.peer
|
||||
}
|
||||
if bestBits < 0 {
|
||||
return amneziawg.Peer{}, false
|
||||
}
|
||||
return bestPeer, true
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// Desired pairs an amneziawg.Instance (the shared, DB-backed shape
|
||||
// internal/amneziawg's own kernel-module Manager also reconciles toward)
|
||||
// with this package's own embedded-only DeviceOptions -- the AWG 3.0 fields
|
||||
// that shared type doesn't carry, see DeviceOptions' doc comment.
|
||||
type Desired struct {
|
||||
Instance amneziawg.Instance
|
||||
Options DeviceOptions
|
||||
}
|
||||
|
||||
// managed is one running embedded interface: the live Device, its UDP relay
|
||||
// sessions, the peer lookup index built from its current peer list, and
|
||||
// enough of its own configuration to decide whether a later Ensure call can
|
||||
// reconfigure it in place or needs to rebuild it from scratch.
|
||||
type managed struct {
|
||||
dev *Device
|
||||
udpRelay *UDPRelay
|
||||
peers *PeerIndex
|
||||
inst amneziawg.Instance
|
||||
structFP string
|
||||
}
|
||||
|
||||
// Manager owns the set of running embedded AmneziaWG interfaces, keyed by
|
||||
// inbound id -- the same shape as internal/amneziawg.Manager (GetManager()
|
||||
// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
|
||||
// caller already familiar with that Manager needs to learn nothing new here.
|
||||
// Every Device this Manager builds gets its TCP forwarder and UDP handler
|
||||
// attached automatically (see ensureLocked), relaying into that instance's
|
||||
// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a
|
||||
// caller only needs to keep calling Ensure/Reconcile with fresh Instance
|
||||
// data; it doesn't need to know relay.go exists at all.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
ifaces map[int]*managed
|
||||
}
|
||||
|
||||
var (
|
||||
managerOnce sync.Once
|
||||
manager *Manager
|
||||
)
|
||||
|
||||
// GetManager returns the process-wide embedded-AmneziaWG manager singleton.
|
||||
func GetManager() *Manager {
|
||||
managerOnce.Do(func() {
|
||||
manager = &Manager{ifaces: map[int]*managed{}}
|
||||
})
|
||||
return manager
|
||||
}
|
||||
|
||||
// Ensure brings inbound d.Instance.Id's embedded interface to the state
|
||||
// d describes, creating it if it doesn't exist yet. A no-op only when
|
||||
// nothing has changed since the last successful Ensure/Reconcile.
|
||||
func (m *Manager) Ensure(d Desired) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.ensureLocked(d)
|
||||
}
|
||||
|
||||
// ensureLocked decides between three actions: nothing changed since the
|
||||
// last apply (skip entirely); only peers/obfuscation/keys/listen_port
|
||||
// changed (reconfigure the existing Device in place via IpcSet, which
|
||||
// already sends replace_peers=true -- see buildUAPIConfig -- so removed
|
||||
// peers are dropped correctly without a full rebuild); or the interface's
|
||||
// own address(es)/MTU changed (these are fixed at netstack-construction
|
||||
// time, so the only option is closing the old Device and building a fresh
|
||||
// one). This is a coarser split than internal/amneziawg's own three-tier
|
||||
// noop/reload/restart fingerprinting (that one also tracks host-side
|
||||
// TPROXY/NDP rules this embedded path has no equivalent of) -- correct and
|
||||
// sufficient for Phase 1; revisit only if reconcile frequency at real scale
|
||||
// makes the address/MTU rebuild path worth avoiding too.
|
||||
func (m *Manager) ensureLocked(d Desired) error {
|
||||
inst, opts := d.Instance, d.Options
|
||||
structFP := addressFingerprint(inst)
|
||||
|
||||
cur, exists := m.ifaces[inst.Id]
|
||||
// Captured before either branch below: peers/AllowedIPs can change
|
||||
// (and so can each peer's IPv6 alias) without the address/MTU
|
||||
// fingerprint changing at all, so both the reconfigure-in-place branch
|
||||
// and the rebuild branch need to diff IPv6 aliases against whatever
|
||||
// this id had before, not just on a rebuild.
|
||||
var oldInst amneziawg.Instance
|
||||
if exists {
|
||||
oldInst = cur.inst
|
||||
}
|
||||
|
||||
if exists && cur.structFP == structFP {
|
||||
conf, err := buildUAPIConfig(inst, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("amneziawgnet: %w", err)
|
||||
}
|
||||
if err := cur.dev.IpcSet(conf); err != nil {
|
||||
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
|
||||
}
|
||||
cur.peers = NewPeerIndex(inst.Peers)
|
||||
cur.inst = inst
|
||||
applyV6Aliases(diffV6Aliases(oldInst, inst))
|
||||
return nil
|
||||
}
|
||||
|
||||
if exists {
|
||||
cur.udpRelay.Close()
|
||||
cur.dev.Close()
|
||||
delete(m.ifaces, inst.Id)
|
||||
}
|
||||
dev, err := NewDevice(inst, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relay := socksRelayForInstance(inst)
|
||||
udpRelay := NewUDPRelay(relay, dev.Stack)
|
||||
inboundID := inst.Id // captured for the closures below, which outlive this call
|
||||
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
|
||||
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
// Re-fetched on every connection, not captured once at attach time:
|
||||
// a reconfigure-in-place (peers added/removed, no rebuild) replaces
|
||||
// cur.peers without ever re-attaching the forwarder, so a stale
|
||||
// captured index would silently miss newly-added peers.
|
||||
_, peers, ok := m.Lookup(inboundID)
|
||||
if !ok {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
|
||||
if !ok {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
relay.RelayTCP(conn, peer.Email, dest)
|
||||
})
|
||||
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
|
||||
_, peers, ok := m.Lookup(inboundID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
peer, ok := peers.Lookup(src.Addr())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
udpRelay.Handle(src, dst, peer.Email, payload)
|
||||
})
|
||||
|
||||
m.ifaces[inst.Id] = &managed{
|
||||
dev: dev,
|
||||
udpRelay: udpRelay,
|
||||
peers: NewPeerIndex(inst.Peers),
|
||||
inst: inst,
|
||||
structFP: structFP,
|
||||
}
|
||||
applyV6Aliases(diffV6Aliases(oldInst, inst))
|
||||
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// socksRelayForInstance derives the loopback SOCKS5 relay address/password
|
||||
// for inst -- both fully determined by its id and the process-wide
|
||||
// password (SOCKSPortForInbound/SocksPassword), so no per-instance state
|
||||
// needs threading through Desired/DeviceOptions for this.
|
||||
func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
|
||||
return SocksRelay{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
|
||||
Password: SocksPassword(),
|
||||
}
|
||||
}
|
||||
|
||||
// addressFingerprint captures the two Instance fields that can't be changed
|
||||
// on a running Device via IpcSet alone (they're fixed when the gVisor
|
||||
// netstack is built) -- everything else (keys, listen port, obfuscation,
|
||||
// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
|
||||
func addressFingerprint(inst amneziawg.Instance) string {
|
||||
return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
|
||||
}
|
||||
|
||||
// Reconcile brings every desired instance's embedded interface up to date
|
||||
// and stops any managed interface whose inbound is no longer desired --
|
||||
// mirroring internal/amneziawg.Manager.Reconcile's per-tick contract.
|
||||
func (m *Manager) Reconcile(desired []Desired) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
want := make(map[int]struct{}, len(desired))
|
||||
for _, d := range desired {
|
||||
want[d.Instance.Id] = struct{}{}
|
||||
}
|
||||
for id, cur := range m.ifaces {
|
||||
if _, ok := want[id]; ok {
|
||||
continue
|
||||
}
|
||||
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
|
||||
cur.udpRelay.Close()
|
||||
cur.dev.Close()
|
||||
delete(m.ifaces, id)
|
||||
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
|
||||
}
|
||||
for _, d := range desired {
|
||||
if err := m.ensureLocked(d); err != nil {
|
||||
logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove tears down inbound id's embedded interface, if any -- mirrors
|
||||
// internal/amneziawg.Manager.Remove, for a caller that needs to drop a
|
||||
// single inbound outside a full Reconcile pass (e.g. the immediate-apply
|
||||
// CRUD path in internal/web/runtime/local.go).
|
||||
func (m *Manager) Remove(id int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cur, exists := m.ifaces[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
|
||||
cur.udpRelay.Close()
|
||||
cur.dev.Close()
|
||||
delete(m.ifaces, id)
|
||||
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
|
||||
}
|
||||
|
||||
// StopAll tears down every managed interface. Called on panel shutdown.
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, cur := range m.ifaces {
|
||||
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
|
||||
cur.udpRelay.Close()
|
||||
cur.dev.Close()
|
||||
delete(m.ifaces, id)
|
||||
}
|
||||
}
|
||||
|
||||
// HasRunning reports whether any embedded interface is currently managed.
|
||||
func (m *Manager) HasRunning() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.ifaces) > 0
|
||||
}
|
||||
|
||||
// Lookup returns the running Device and PeerIndex for inbound id, if any --
|
||||
// the forwarder/UDP-handler closures ensureLocked attaches use this to
|
||||
// re-fetch the current peer index on every connection (see ensureLocked's
|
||||
// comment on why), and it's equally available to a test harness or any
|
||||
// other caller that wants read access to a managed interface's state.
|
||||
func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cur, exists := m.ifaces[id]
|
||||
if !exists {
|
||||
return nil, nil, false
|
||||
}
|
||||
return cur.dev, cur.peers, true
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// TestManagerLifecycle exercises Ensure/Reconcile's reconfigure-in-place vs.
|
||||
// rebuild split (see ensureLocked's doc comment) and Reconcile's stop path,
|
||||
// using a throwaway Manager rather than the process-wide singleton so this
|
||||
// test doesn't interact with any other test's state.
|
||||
func TestManagerLifecycle(t *testing.T) {
|
||||
priv, pub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
|
||||
m := &Manager{ifaces: map[int]*managed{}}
|
||||
inst := amneziawg.Instance{
|
||||
Id: 3,
|
||||
InterfaceName: "awgtest3",
|
||||
ListenPort: 58714,
|
||||
PrivateKey: priv,
|
||||
PublicKey: pub,
|
||||
Address: []string{"10.203.0.1/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: amneziawg.Obfuscation20{
|
||||
Jc: 4, Jmin: 40, Jmax: 70,
|
||||
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||
},
|
||||
}
|
||||
defer m.StopAll()
|
||||
|
||||
if err := m.Ensure(Desired{Instance: inst}); err != nil {
|
||||
t.Fatalf("Ensure (create): %v", err)
|
||||
}
|
||||
if !m.HasRunning() {
|
||||
t.Fatal("HasRunning() = false after Ensure created an interface")
|
||||
}
|
||||
dev1, _, ok := m.Lookup(inst.Id)
|
||||
if !ok {
|
||||
t.Fatal("Lookup after Ensure: not found")
|
||||
}
|
||||
|
||||
// Same Instance again: same address fingerprint, so this should
|
||||
// reconfigure the existing Device via IpcSet rather than rebuild it --
|
||||
// verify by checking the *Device pointer survived unchanged.
|
||||
if err := m.Ensure(Desired{Instance: inst}); err != nil {
|
||||
t.Fatalf("Ensure (unchanged): %v", err)
|
||||
}
|
||||
dev2, _, ok := m.Lookup(inst.Id)
|
||||
if !ok {
|
||||
t.Fatal("Lookup after second Ensure: not found")
|
||||
}
|
||||
if dev1 != dev2 {
|
||||
t.Error("Ensure with an unchanged Instance rebuilt the Device; expected an in-place reconfigure")
|
||||
}
|
||||
|
||||
// Changing the interface address is structural (fixed at netstack
|
||||
// construction time) and must force a rebuild -- verify by checking the
|
||||
// *Device pointer changed.
|
||||
changed := inst
|
||||
changed.Address = []string{"10.203.1.1/24"}
|
||||
if err := m.Ensure(Desired{Instance: changed}); err != nil {
|
||||
t.Fatalf("Ensure (address changed): %v", err)
|
||||
}
|
||||
dev3, _, ok := m.Lookup(inst.Id)
|
||||
if !ok {
|
||||
t.Fatal("Lookup after address-changing Ensure: not found")
|
||||
}
|
||||
if dev3 == dev2 {
|
||||
t.Error("Ensure with a changed address reconfigured in place; expected a rebuild")
|
||||
}
|
||||
|
||||
// Reconcile with nothing desired stops every managed interface.
|
||||
m.Reconcile(nil)
|
||||
if m.HasRunning() {
|
||||
t.Error("HasRunning() = true after Reconcile([]) should have stopped everything")
|
||||
}
|
||||
if _, _, ok := m.Lookup(inst.Id); ok {
|
||||
t.Error("Lookup succeeded after Reconcile([]) removed the interface")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
|
||||
// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
|
||||
// the panel process, as an alternative to internal/amneziawg's
|
||||
// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
|
||||
// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
|
||||
// xray-core's own proxy/wireguard support) terminates each tunnel, and a
|
||||
// forwarder recovers each connection's real, dynamically-arbitrary
|
||||
// destination for the caller to relay onward (see Phase 2 of the migration
|
||||
// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
|
||||
// sniffing for free).
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/buffer"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
||||
)
|
||||
|
||||
// stackTun implements amneziawg-go's tun.Device directly against a gVisor
|
||||
// channel endpoint, the same approach amneziawg-go's own tun/netstack
|
||||
// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
|
||||
// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
|
||||
// type keeps it unexported), so this is a local, from-source reimplementation
|
||||
// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
|
||||
// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
|
||||
// package needs.
|
||||
type stackTun struct {
|
||||
ep *channel.Endpoint
|
||||
stack *stack.Stack
|
||||
events chan awgtun.Event
|
||||
notifyHandle *channel.NotificationHandle
|
||||
incomingPacket chan *buffer.View
|
||||
mtu int
|
||||
}
|
||||
|
||||
// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
|
||||
// local addresses (interface address(es), one per family) and returns the
|
||||
// underlying *stack.Stack alongside it so a caller can attach a forwarder
|
||||
// (see forwarder.go / udp.go).
|
||||
func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
|
||||
opts := stack.Options{
|
||||
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
|
||||
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
|
||||
// HandleLocal must stay false: promiscuous+spoofing mode (see
|
||||
// forwarder.go) is what lets a destination other than the stack's
|
||||
// own configured address reach the forwarder at all.
|
||||
HandleLocal: false,
|
||||
}
|
||||
dev := &stackTun{
|
||||
ep: channel.New(1024, uint32(mtu), ""),
|
||||
stack: stack.New(opts),
|
||||
events: make(chan awgtun.Event, 10),
|
||||
incomingPacket: make(chan *buffer.View),
|
||||
mtu: mtu,
|
||||
}
|
||||
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
|
||||
if err := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); err != nil {
|
||||
return nil, nil, fmt.Errorf("amneziawgnet: enable TCP SACK: %s", err)
|
||||
}
|
||||
dev.notifyHandle = dev.ep.AddNotify(dev)
|
||||
if err := dev.stack.CreateNIC(1, dev.ep); err != nil {
|
||||
return nil, nil, fmt.Errorf("amneziawgnet: CreateNIC: %s", err)
|
||||
}
|
||||
|
||||
var hasV4, hasV6 bool
|
||||
for _, ip := range localAddresses {
|
||||
var protoNumber tcpip.NetworkProtocolNumber
|
||||
switch {
|
||||
case ip.Is4():
|
||||
protoNumber = ipv4.ProtocolNumber
|
||||
hasV4 = true
|
||||
case ip.Is6():
|
||||
protoNumber = ipv6.ProtocolNumber
|
||||
hasV6 = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
protoAddr := tcpip.ProtocolAddress{
|
||||
Protocol: protoNumber,
|
||||
AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(),
|
||||
}
|
||||
if err := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil {
|
||||
return nil, nil, fmt.Errorf("amneziawgnet: AddProtocolAddress(%v): %s", ip, err)
|
||||
}
|
||||
}
|
||||
if hasV4 {
|
||||
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1})
|
||||
}
|
||||
if hasV6 {
|
||||
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1})
|
||||
}
|
||||
dev.events <- awgtun.EventUp
|
||||
return dev, dev.stack, nil
|
||||
}
|
||||
|
||||
func (t *stackTun) Name() (string, error) { return "amneziawgnet", nil }
|
||||
func (t *stackTun) File() *os.File { return nil }
|
||||
func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
|
||||
func (t *stackTun) MTU() (int, error) { return t.mtu, nil }
|
||||
func (t *stackTun) BatchSize() int { return 1 }
|
||||
|
||||
func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
|
||||
view, ok := <-t.incomingPacket
|
||||
if !ok {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
n, err := view.Read(buf[0][offset:])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sizes[0] = n
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
|
||||
for _, b := range buf {
|
||||
packet := b[offset:]
|
||||
if len(packet) == 0 {
|
||||
continue
|
||||
}
|
||||
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)})
|
||||
switch packet[0] >> 4 {
|
||||
case 4:
|
||||
t.ep.InjectInbound(header.IPv4ProtocolNumber, pkb)
|
||||
case 6:
|
||||
t.ep.InjectInbound(header.IPv6ProtocolNumber, pkb)
|
||||
default:
|
||||
return 0, syscall.EAFNOSUPPORT
|
||||
}
|
||||
}
|
||||
return len(buf), nil
|
||||
}
|
||||
|
||||
func (t *stackTun) WriteNotify() {
|
||||
pkt := t.ep.Read()
|
||||
if pkt == nil {
|
||||
return
|
||||
}
|
||||
view := pkt.ToView()
|
||||
pkt.DecRef()
|
||||
t.incomingPacket <- view
|
||||
}
|
||||
|
||||
func (t *stackTun) Close() error {
|
||||
t.stack.RemoveNIC(1)
|
||||
t.stack.Close()
|
||||
t.ep.RemoveNotify(t.notifyHandle)
|
||||
t.ep.Close()
|
||||
if t.events != nil {
|
||||
close(t.events)
|
||||
}
|
||||
if t.incomingPacket != nil {
|
||||
close(t.incomingPacket)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
|
||||
// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
|
||||
// packets addressed to a destination other than the stack's own configured
|
||||
// local address. Safe to call from both (and more than once): gVisor's
|
||||
// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
|
||||
// that accumulates or needs undoing between calls.
|
||||
func enablePromiscuousRouting(gstack *stack.Stack) {
|
||||
gstack.SetPromiscuousMode(1, true)
|
||||
gstack.SetSpoofing(1, true)
|
||||
}
|
||||
|
||||
// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
|
||||
// stdlib netip.Addr type the rest of this package and its callers use.
|
||||
func addrFromTcpip(a tcpip.Address) netip.Addr {
|
||||
if a.Len() == 4 {
|
||||
var b [4]byte
|
||||
copy(b[:], a.AsSlice())
|
||||
return netip.AddrFrom4(b)
|
||||
}
|
||||
var b [16]byte
|
||||
copy(b[:], a.AsSlice())
|
||||
return netip.AddrFrom16(b)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Phase 2: relaying a recovered tunnel connection into Xray's own,
|
||||
// completely stock SOCKS5 inbound -- authenticating as the owning peer's
|
||||
// email -- is what gives every embedded AmneziaWG connection real, native
|
||||
// Xray stats/routing/sniffing with no Xray-core fork at all (Finding 3 of
|
||||
// the migration plan: a stock SOCKS5 inbound sets its per-connection stats
|
||||
// identity directly from the SOCKS5 auth username).
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// SocksRelay describes the loopback SOCKS5 inbound decapsulated AmneziaWG
|
||||
// traffic gets relayed into.
|
||||
type SocksRelay struct {
|
||||
// Addr is the SOCKS5 inbound's own address, e.g. "127.0.0.1:11500".
|
||||
Addr string
|
||||
// Password is shared across every account. This traffic never leaves
|
||||
// loopback, so the password is not a real secrecy boundary -- it only
|
||||
// needs to satisfy Xray's SOCKS5 inbound requiring *some* username/
|
||||
// password auth before it will accept a connection and use the
|
||||
// username as the stats identity. Document this reasoning wherever a
|
||||
// caller generates or displays it, so it's never mistaken later for a
|
||||
// real credential.
|
||||
Password string
|
||||
}
|
||||
|
||||
// SocksInboundSettings builds the JSON `settings` block for a stock Xray
|
||||
// SOCKS5 inbound with one username/password account per email, all sharing
|
||||
// password (see SocksRelay's doc comment). udp:true is required: RelayUDP
|
||||
// depends on the inbound accepting UDP ASSOCIATE, not just CONNECT.
|
||||
func SocksInboundSettings(emails []string, password string) ([]byte, error) {
|
||||
type account struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
settings := struct {
|
||||
Auth string `json:"auth"`
|
||||
UDP bool `json:"udp"`
|
||||
Accounts []account `json:"accounts"`
|
||||
}{Auth: "password", UDP: true}
|
||||
for _, email := range emails {
|
||||
settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
|
||||
}
|
||||
return json.Marshal(settings)
|
||||
}
|
||||
|
||||
// RelayTCP dials r.Addr, authenticates as email, issues a SOCKS5 CONNECT to
|
||||
// dest, and pipes bytes both ways until either side closes or errors.
|
||||
// Blocks until the relay ends; meant to be called from (or as) an
|
||||
// AttachTCPForwarder handler, which already runs each connection on its own
|
||||
// goroutine.
|
||||
func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrPort) {
|
||||
defer conn.Close()
|
||||
|
||||
auth := &proxy.Auth{User: email, Password: r.Password}
|
||||
dialer, err := proxy.SOCKS5("tcp", r.Addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: RelayTCP: build SOCKS5 dialer: %v", err)
|
||||
return
|
||||
}
|
||||
upstream, err := dialer.Dial("tcp", dest.String())
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: RelayTCP: SOCKS5 CONNECT to %s as %q: %v", dest, email, err)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
|
||||
// socks5UDPSession is one established SOCKS5 UDP ASSOCIATE session: udpConn
|
||||
// is the actual socket packets are sent to (and replies read from); ctrl is
|
||||
// the TCP control connection that must stay open for the session's
|
||||
// lifetime -- per RFC 1928, closing it tears the association down.
|
||||
type socks5UDPSession struct {
|
||||
ctrl net.Conn
|
||||
udpConn *net.UDPConn
|
||||
}
|
||||
|
||||
// newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
|
||||
// and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
|
||||
// client (used by RelayTCP above) only implements CONNECT, and xray-core's
|
||||
// own proxy/socks/client.go is written against its internal transport
|
||||
// types, not reusable as a standalone dialer -- so this is a small, direct,
|
||||
// from-the-RFC implementation rather than an existing library call.
|
||||
func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||
ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
|
||||
}
|
||||
if err := socks5Handshake(ctrl, user, password); err != nil {
|
||||
ctrl.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// UDP ASSOCIATE, dst 0.0.0.0:0 ("I don't know my own source yet, and I
|
||||
// don't need to specify one for a loopback relay").
|
||||
if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
ctrl.Close()
|
||||
return nil, fmt.Errorf("amneziawgnet: send UDP ASSOCIATE request: %w", err)
|
||||
}
|
||||
bind, err := readSocks5Reply(ctrl)
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 UDP relay endpoint %s: %w", bind, err)
|
||||
}
|
||||
return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}, nil
|
||||
}
|
||||
|
||||
// socks5Handshake performs the version greeting and (if the server
|
||||
// requires it) username/password auth. Xray's SOCKS5 inbound with
|
||||
// auth:"password" always requires it; the no-auth branch exists so this
|
||||
// helper isn't silently wrong against a differently-configured server.
|
||||
func socks5Handshake(conn net.Conn, user, password string) error {
|
||||
if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
|
||||
return fmt.Errorf("amneziawgnet: send SOCKS5 greeting: %w", err)
|
||||
}
|
||||
var resp [2]byte
|
||||
if _, err := io.ReadFull(conn, resp[:]); err != nil {
|
||||
return fmt.Errorf("amneziawgnet: read SOCKS5 greeting reply: %w", err)
|
||||
}
|
||||
if resp[0] != 0x05 {
|
||||
return fmt.Errorf("amneziawgnet: unexpected SOCKS5 version %d", resp[0])
|
||||
}
|
||||
switch resp[1] {
|
||||
case 0x00: // no auth required
|
||||
return nil
|
||||
case 0x02: // username/password
|
||||
req := make([]byte, 0, 3+len(user)+len(password))
|
||||
req = append(req, 0x01, byte(len(user)))
|
||||
req = append(req, user...)
|
||||
req = append(req, byte(len(password)))
|
||||
req = append(req, password...)
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return fmt.Errorf("amneziawgnet: send SOCKS5 auth: %w", err)
|
||||
}
|
||||
var authResp [2]byte
|
||||
if _, err := io.ReadFull(conn, authResp[:]); err != nil {
|
||||
return fmt.Errorf("amneziawgnet: read SOCKS5 auth reply: %w", err)
|
||||
}
|
||||
if authResp[1] != 0x00 {
|
||||
return fmt.Errorf("amneziawgnet: SOCKS5 auth rejected (status %d)", authResp[1])
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("amneziawgnet: SOCKS5 server offered unsupported auth method %d", resp[1])
|
||||
}
|
||||
}
|
||||
|
||||
// readSocks5Reply reads a SOCKS5 reply (the common format shared by CONNECT
|
||||
// and UDP ASSOCIATE replies) and returns its bound address.
|
||||
func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply header: %w", err)
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: unexpected SOCKS5 reply version %d", hdr[0])
|
||||
}
|
||||
if hdr[1] != 0x00 {
|
||||
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: SOCKS5 request failed (reply code %d)", hdr[1])
|
||||
}
|
||||
addr, err := readSocks5Addr(r, hdr[3])
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, err
|
||||
}
|
||||
var portBytes [2]byte
|
||||
if _, err := io.ReadFull(r, portBytes[:]); err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply port: %w", err)
|
||||
}
|
||||
return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
|
||||
}
|
||||
|
||||
// readSocks5Addr reads the address portion of a SOCKS5 reply for the given
|
||||
// address type (IPv4, IPv6, or domain -- resolved locally since a loopback
|
||||
// Xray inbound is not expected to reply with one, but it's cheap to handle
|
||||
// correctly rather than fail oddly if it ever does).
|
||||
func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
return netip.AddrFrom4(b), nil
|
||||
case 0x04:
|
||||
var b [16]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
return netip.AddrFrom16(b), nil
|
||||
case 0x03:
|
||||
var l [1]byte
|
||||
if _, err := io.ReadFull(r, l[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
name := make([]byte, l[0])
|
||||
if _, err := io.ReadFull(r, name); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
resolved, err := net.ResolveIPAddr("ip", string(name))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("amneziawgnet: resolve SOCKS5 domain reply %q: %w", name, err)
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(resolved.IP)
|
||||
if !ok {
|
||||
return netip.Addr{}, fmt.Errorf("amneziawgnet: unparseable resolved SOCKS5 domain reply address")
|
||||
}
|
||||
return addr, nil
|
||||
default:
|
||||
return netip.Addr{}, fmt.Errorf("amneziawgnet: unsupported SOCKS5 address type %d", atyp)
|
||||
}
|
||||
}
|
||||
|
||||
// Close ends the UDP ASSOCIATE session: closing ctrl tells the SOCKS5
|
||||
// server to tear down its relay side too (RFC 1928).
|
||||
func (s *socks5UDPSession) Close() error {
|
||||
s.udpConn.Close()
|
||||
return s.ctrl.Close()
|
||||
}
|
||||
|
||||
// sendTo wraps payload in a SOCKS5 UDP request header addressed to dest and
|
||||
// sends it to the session's relay endpoint.
|
||||
func (s *socks5UDPSession) sendTo(dest netip.AddrPort, payload []byte) error {
|
||||
hdr := make([]byte, 0, 3+1+16+2+len(payload))
|
||||
hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0, no fragmentation)
|
||||
if dest.Addr().Is4() {
|
||||
b := dest.Addr().As4()
|
||||
hdr = append(hdr, 0x01)
|
||||
hdr = append(hdr, b[:]...)
|
||||
} else {
|
||||
b := dest.Addr().As16()
|
||||
hdr = append(hdr, 0x04)
|
||||
hdr = append(hdr, b[:]...)
|
||||
}
|
||||
var portBytes [2]byte
|
||||
binary.BigEndian.PutUint16(portBytes[:], dest.Port())
|
||||
hdr = append(hdr, portBytes[:]...)
|
||||
hdr = append(hdr, payload...)
|
||||
_, err := s.udpConn.Write(hdr)
|
||||
return err
|
||||
}
|
||||
|
||||
// receive reads one reply datagram into buf, returning the address the
|
||||
// SOCKS5 server says it came from and the actual payload (a sub-slice of
|
||||
// buf -- valid only until the next receive call).
|
||||
func (s *socks5UDPSession) receive(buf []byte) (netip.AddrPort, []byte, error) {
|
||||
n, err := s.udpConn.Read(buf)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, nil, err
|
||||
}
|
||||
data := buf[:n]
|
||||
if len(data) < 4 {
|
||||
return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: short SOCKS5 UDP reply (%d bytes)", n)
|
||||
}
|
||||
atyp := data[3]
|
||||
data = data[4:]
|
||||
addr, err := readSocks5Addr(bytesReader{data}, atyp)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, nil, err
|
||||
}
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
data = data[4:]
|
||||
case 0x04:
|
||||
data = data[16:]
|
||||
}
|
||||
if len(data) < 2 {
|
||||
return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: truncated SOCKS5 UDP reply port")
|
||||
}
|
||||
port := binary.BigEndian.Uint16(data[:2])
|
||||
return netip.AddrPortFrom(addr, port), data[2:], nil
|
||||
}
|
||||
|
||||
// bytesReader is the minimal io.Reader readSocks5Addr needs, over an
|
||||
// in-memory slice that's already fully available (a received UDP
|
||||
// datagram) -- avoids pulling in bytes.Reader just for this.
|
||||
type bytesReader struct{ b []byte }
|
||||
|
||||
func (r bytesReader) Read(p []byte) (int, error) {
|
||||
n := copy(p, r.b)
|
||||
if n < len(p) {
|
||||
return n, io.ErrUnexpectedEOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UDPRelay tracks one SOCKS5 UDP ASSOCIATE session per source (tunnel-
|
||||
// internal client) flow, relaying each into r's SOCKS5 inbound and writing
|
||||
// replies back through gstack -- the UDP counterpart of RelayTCP, meant to
|
||||
// be driven by an AttachUDPHandler callback (see udp.go).
|
||||
type UDPRelay struct {
|
||||
relay SocksRelay
|
||||
gstack *stack.Stack
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*socks5UDPSession
|
||||
}
|
||||
|
||||
// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
|
||||
func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay {
|
||||
return &UDPRelay{relay: relay, gstack: gstack, sessions: map[string]*socks5UDPSession{}}
|
||||
}
|
||||
|
||||
// Handle relays one packet from src (the peer's tunnel-internal source) to
|
||||
// dst (its real, recovered destination), opening a fresh SOCKS5 UDP
|
||||
// ASSOCIATE session for src the first time it's seen (authenticating as
|
||||
// email, so Xray attributes the whole flow's stats to the right peer) and
|
||||
// reusing it for subsequent packets from the same src.
|
||||
func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) {
|
||||
u.mu.Lock()
|
||||
sess, ok := u.sessions[src.String()]
|
||||
u.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
var err error
|
||||
sess, err = newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.sessions[src.String()] = sess
|
||||
u.mu.Unlock()
|
||||
go u.pump(src, sess)
|
||||
}
|
||||
if err := sess.sendTo(dst, payload); err != nil {
|
||||
logger.Warningf("amneziawgnet: UDPRelay: send to %s: %v", dst, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pump reads replies from sess and writes them back into the tunnel toward
|
||||
// src until the session errors out or goes idle for 2 minutes, then tears
|
||||
// it down -- both the map entry and the underlying SOCKS5 association.
|
||||
func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) {
|
||||
defer func() {
|
||||
u.mu.Lock()
|
||||
delete(u.sessions, src.String())
|
||||
u.mu.Unlock()
|
||||
sess.Close()
|
||||
}()
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
_ = sess.udpConn.SetReadDeadline(time.Now().Add(2 * time.Minute))
|
||||
from, payload, err := sess.receive(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := WriteUDPReply(u.gstack, from, src, payload); err != nil {
|
||||
logger.Warningf("amneziawgnet: UDPRelay: reply write: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears down every open session. Call when the owning Device is
|
||||
// closed.
|
||||
func (u *UDPRelay) Close() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
for k, s := range u.sessions {
|
||||
s.Close()
|
||||
delete(u.sessions, k)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// TestSocksRelayAgainstRealXray is Phase 2's real end-to-end proof: a
|
||||
// genuine amneziawg-go client completes a real handshake against a Device
|
||||
// built by NewDevice, dials a real TCP echo server and sends a real UDP
|
||||
// echo datagram, and this package's own AttachTCPForwarder/AttachUDPHandler
|
||||
// handlers relay both through RelayTCP/UDPRelay into an *actual xray-core
|
||||
// process* (not a mock) running a SOCKS5 inbound built by
|
||||
// SocksInboundSettings. Verifies real data round-trips on both protocols,
|
||||
// then greps the real process's own debug log for
|
||||
// "user>>>{email}>>>traffic>>>{up,down}link" -- the same proof Finding 3 of
|
||||
// the migration plan established manually in Phase 0, now permanent,
|
||||
// repo-owned test infrastructure. The UDP half in particular is the first
|
||||
// real test of this package's hand-rolled SOCKS5 UDP ASSOCIATE client
|
||||
// (relay.go) against an independent, authoritative implementation of the
|
||||
// protocol rather than a mock this same session wrote.
|
||||
//
|
||||
// Skipped unless XRAY_E2E_BINARY points at an xray executable built from
|
||||
// the same xray-core version as go.mod, matching internal/xray's own
|
||||
// TestXrayAPI_E2E convention:
|
||||
//
|
||||
// go install github.com/xtls/xray-core/main@<version from go.mod>
|
||||
// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/amneziawgnet -run TestSocksRelayAgainstRealXray -v
|
||||
func TestSocksRelayAgainstRealXray(t *testing.T) {
|
||||
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||
if bin == "" {
|
||||
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||
}
|
||||
|
||||
localIP, ok := firstNonLoopbackIPv4()
|
||||
if !ok {
|
||||
t.Skip("no non-loopback IPv4 address available on this host")
|
||||
}
|
||||
|
||||
const wantEmail = "e2e-peer@example.com"
|
||||
const socksPassword = "loopback-only-not-a-real-secret"
|
||||
|
||||
// --- real TCP + UDP echo servers on a real, non-loopback address ---
|
||||
// (dialing 127.0.0.1 as a tunnel-internal destination hangs -- gVisor
|
||||
// won't route loopback out an arbitrary NIC -- so the client dials
|
||||
// localIP instead; it must still be a *real* address since the actual
|
||||
// relay leg is a genuine OS-level dial from the xray-core process, not
|
||||
// anything inside the tunnel's virtual netstack.)
|
||||
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
|
||||
defer tcpEcho.Close()
|
||||
udpEcho, udpEchoAddr := startUDPEcho(t, localIP)
|
||||
defer udpEcho.Close()
|
||||
|
||||
// --- real embedded AmneziaWG server + client, same shape as Phase 1's tests ---
|
||||
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate server keypair: %v", err)
|
||||
}
|
||||
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate client keypair: %v", err)
|
||||
}
|
||||
|
||||
const listenPort = 58715
|
||||
inst := amneziawg.Instance{
|
||||
Id: 4,
|
||||
InterfaceName: "awgtest4",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{"10.204.0.1/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: amneziawg.Obfuscation20{
|
||||
Jc: 4, Jmin: 40, Jmax: 70,
|
||||
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||
},
|
||||
Peers: []amneziawg.Peer{{
|
||||
Email: wantEmail,
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{"10.204.0.2/32"},
|
||||
}},
|
||||
}
|
||||
dev, err := NewDevice(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer dev.Close()
|
||||
idx := NewPeerIndex(inst.Peers)
|
||||
|
||||
// --- real xray-core process with a SOCKS5 inbound built by this package ---
|
||||
socksPort := freePort(t)
|
||||
settingsJSON, err := SocksInboundSettings([]string{wantEmail}, socksPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("SocksInboundSettings: %v", err)
|
||||
}
|
||||
var rawSettings any
|
||||
if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
|
||||
t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
|
||||
}
|
||||
xrayCfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "debug"},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1",
|
||||
"port": socksPort,
|
||||
"protocol": "socks",
|
||||
"settings": rawSettings,
|
||||
"tag": "awg-e2e-socks",
|
||||
},
|
||||
},
|
||||
"outbounds": []any{
|
||||
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||
},
|
||||
"policy": map[string]any{
|
||||
"levels": map[string]any{
|
||||
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
|
||||
},
|
||||
},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal xray config: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
|
||||
t.Fatalf("write xray config: %v", err)
|
||||
}
|
||||
|
||||
var xrayLog syncBuffer
|
||||
cmd := exec.Command(bin, "-c", cfgPath)
|
||||
cmd.Stdout = &xrayLog
|
||||
cmd.Stderr = &xrayLog
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start xray: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
waitForPort(t, socksPort)
|
||||
|
||||
socksAddr := fmt.Sprintf("127.0.0.1:%d", socksPort)
|
||||
relay := SocksRelay{Addr: socksAddr, Password: socksPassword}
|
||||
udpRelay := NewUDPRelay(relay, dev.Stack)
|
||||
defer udpRelay.Close()
|
||||
|
||||
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
|
||||
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
|
||||
if !ok {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
relay.RelayTCP(conn, peer.Email, dest)
|
||||
})
|
||||
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
|
||||
peer, ok := idx.Lookup(src.Addr())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
udpRelay.Handle(src, dst, peer.Email, payload)
|
||||
})
|
||||
|
||||
// --- real client, real handshake, real traffic through the whole chain ---
|
||||
clientTun, clientNet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("10.204.0.2")},
|
||||
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
|
||||
if err != nil {
|
||||
t.Fatalf("client CreateNetTUN: %v", err)
|
||||
}
|
||||
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||
defer clientDev.Close()
|
||||
|
||||
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("client key to hex: %v", err)
|
||||
}
|
||||
serverPubHex, err := wireguard.KeyToHex(serverPub)
|
||||
if err != nil {
|
||||
t.Fatalf("server key to hex: %v", err)
|
||||
}
|
||||
clientConf := fmt.Sprintf(
|
||||
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
|
||||
clientPrivHex, serverPubHex, listenPort)
|
||||
if err := clientDev.IpcSet(clientConf); err != nil {
|
||||
t.Fatalf("client IpcSet: %v", err)
|
||||
}
|
||||
if err := clientDev.Up(); err != nil {
|
||||
t.Fatalf("client Up: %v", err)
|
||||
}
|
||||
|
||||
// TCP round trip.
|
||||
const tcpMsg = "hello over amneziawgnet+socks5+xray"
|
||||
dialDeadline := time.Now().Add(10 * time.Second)
|
||||
var tcpConn interface {
|
||||
Write([]byte) (int, error)
|
||||
Read([]byte) (int, error)
|
||||
Close() error
|
||||
}
|
||||
for {
|
||||
c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
|
||||
if dialErr == nil {
|
||||
tcpConn = c
|
||||
break
|
||||
}
|
||||
if time.Now().After(dialDeadline) {
|
||||
t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
defer tcpConn.Close()
|
||||
if _, err := tcpConn.Write([]byte(tcpMsg)); err != nil {
|
||||
t.Fatalf("client TCP write: %v", err)
|
||||
}
|
||||
tcpBuf := make([]byte, len(tcpMsg))
|
||||
if _, err := readFull(tcpConn, tcpBuf, 10*time.Second); err != nil {
|
||||
t.Fatalf("client TCP read: %v", err)
|
||||
}
|
||||
if string(tcpBuf) != tcpMsg {
|
||||
t.Errorf("TCP echo = %q, want %q", tcpBuf, tcpMsg)
|
||||
}
|
||||
|
||||
// UDP round trip.
|
||||
const udpMsg = "hello-udp-over-socks5"
|
||||
uconn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, udpEchoAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("client DialUDPAddrPort: %v", err)
|
||||
}
|
||||
defer uconn.Close()
|
||||
udpDeadline := time.Now().Add(10 * time.Second)
|
||||
var udpBuf [256]byte
|
||||
var gotUDP string
|
||||
for time.Now().Before(udpDeadline) {
|
||||
_ = uconn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
if _, err := uconn.Write([]byte(udpMsg)); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = uconn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
n, err := uconn.Read(udpBuf[:])
|
||||
if err == nil {
|
||||
gotUDP = string(udpBuf[:n])
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotUDP != udpMsg {
|
||||
t.Fatalf("UDP echo = %q, want %q (xray log follows)\n%s", gotUDP, udpMsg, xrayLog.String())
|
||||
}
|
||||
|
||||
// Real per-peer stats attribution: stop xray so its log is complete, then
|
||||
// look for both directions' counters keyed by the peer's real email --
|
||||
// the exact proof Finding 3 established manually in Phase 0.
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
log := xrayLog.String()
|
||||
wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
|
||||
wantDown := fmt.Sprintf("user>>>%s>>>traffic>>>downlink", wantEmail)
|
||||
if !strings.Contains(log, wantUp) {
|
||||
t.Errorf("xray log missing uplink stats counter %q\nfull log:\n%s", wantUp, log)
|
||||
}
|
||||
if !strings.Contains(log, wantDown) {
|
||||
t.Errorf("xray log missing downlink stats counter %q\nfull log:\n%s", wantDown, log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike
|
||||
// TestSocksRelayAgainstRealXray above (which builds a Device and attaches
|
||||
// RelayTCP/UDPRelay by hand), this drives everything through the public
|
||||
// Manager.Ensure entry point the real app actually calls -- confirming
|
||||
// ensureLocked's own forwarder/UDP-handler attachment (added this phase)
|
||||
// really does relay a fresh Device's traffic into Xray with zero manual
|
||||
// wiring from the caller. Uses the exact port/password
|
||||
// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so
|
||||
// this only passes if that internal derivation and the externally-visible
|
||||
// contract genuinely agree.
|
||||
func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
|
||||
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||
if bin == "" {
|
||||
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||
}
|
||||
localIP, ok := firstNonLoopbackIPv4()
|
||||
if !ok {
|
||||
t.Skip("no non-loopback IPv4 address available on this host")
|
||||
}
|
||||
|
||||
const wantEmail = "manager-e2e-peer@example.com"
|
||||
const listenPort = 58716
|
||||
const inboundID = 5
|
||||
|
||||
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
|
||||
defer tcpEcho.Close()
|
||||
|
||||
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate server keypair: %v", err)
|
||||
}
|
||||
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate client keypair: %v", err)
|
||||
}
|
||||
|
||||
inst := amneziawg.Instance{
|
||||
Id: inboundID,
|
||||
InterfaceName: "awgtest5",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{"10.205.0.1/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: amneziawg.Obfuscation20{
|
||||
Jc: 4, Jmin: 40, Jmax: 70,
|
||||
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||
},
|
||||
Peers: []amneziawg.Peer{{
|
||||
Email: wantEmail,
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{"10.205.0.2/32"},
|
||||
}},
|
||||
}
|
||||
|
||||
// A real xray-core process with a SOCKS5 inbound at exactly the port and
|
||||
// password ensureLocked will derive on its own for this instance --
|
||||
// SocksPassword() is cached (sync.Once), so calling it here first and
|
||||
// again inside Manager.Ensure below returns the identical value.
|
||||
socksPort := SOCKSPortForInbound(inboundID)
|
||||
password := SocksPassword()
|
||||
settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password)
|
||||
if err != nil {
|
||||
t.Fatalf("SocksInboundSettings: %v", err)
|
||||
}
|
||||
var rawSettings any
|
||||
if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
|
||||
t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
|
||||
}
|
||||
xrayCfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "debug"},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1",
|
||||
"port": socksPort,
|
||||
"protocol": "socks",
|
||||
"settings": rawSettings,
|
||||
"tag": "awg-e2e-manager",
|
||||
},
|
||||
},
|
||||
"outbounds": []any{
|
||||
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||
},
|
||||
"policy": map[string]any{
|
||||
"levels": map[string]any{
|
||||
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
|
||||
},
|
||||
},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal xray config: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
|
||||
t.Fatalf("write xray config: %v", err)
|
||||
}
|
||||
|
||||
var xrayLog syncBuffer
|
||||
cmd := exec.Command(bin, "-c", cfgPath)
|
||||
cmd.Stdout = &xrayLog
|
||||
cmd.Stderr = &xrayLog
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start xray: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
waitForPort(t, socksPort)
|
||||
|
||||
// A throwaway Manager, not the process-wide singleton, so this test
|
||||
// doesn't interact with any other test's state.
|
||||
m := &Manager{ifaces: map[int]*managed{}}
|
||||
defer m.StopAll()
|
||||
if err := m.Ensure(Desired{Instance: inst}); err != nil {
|
||||
t.Fatalf("Manager.Ensure: %v", err)
|
||||
}
|
||||
dev, _, ok := m.Lookup(inboundID)
|
||||
if !ok {
|
||||
t.Fatal("Lookup after Ensure: not found")
|
||||
}
|
||||
defer dev.Close() // StopAll would also do this; explicit for clarity
|
||||
|
||||
clientTun, clientNet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("10.205.0.2")},
|
||||
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
|
||||
if err != nil {
|
||||
t.Fatalf("client CreateNetTUN: %v", err)
|
||||
}
|
||||
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||
defer clientDev.Close()
|
||||
|
||||
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("client key to hex: %v", err)
|
||||
}
|
||||
serverPubHex, err := wireguard.KeyToHex(serverPub)
|
||||
if err != nil {
|
||||
t.Fatalf("server key to hex: %v", err)
|
||||
}
|
||||
clientConf := fmt.Sprintf(
|
||||
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
|
||||
clientPrivHex, serverPubHex, listenPort)
|
||||
if err := clientDev.IpcSet(clientConf); err != nil {
|
||||
t.Fatalf("client IpcSet: %v", err)
|
||||
}
|
||||
if err := clientDev.Up(); err != nil {
|
||||
t.Fatalf("client Up: %v", err)
|
||||
}
|
||||
|
||||
const tcpMsg = "hello via Manager.Ensure's automatic relay wiring"
|
||||
dialDeadline := time.Now().Add(10 * time.Second)
|
||||
var conn net.Conn
|
||||
for {
|
||||
c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
|
||||
if dialErr == nil {
|
||||
conn = c
|
||||
break
|
||||
}
|
||||
if time.Now().After(dialDeadline) {
|
||||
t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.Write([]byte(tcpMsg)); err != nil {
|
||||
t.Fatalf("client TCP write: %v", err)
|
||||
}
|
||||
buf := make([]byte, len(tcpMsg))
|
||||
if _, err := readFull(conn, buf, 10*time.Second); err != nil {
|
||||
t.Fatalf("client TCP read: %v", err)
|
||||
}
|
||||
if string(buf) != tcpMsg {
|
||||
t.Errorf("TCP echo = %q, want %q", buf, tcpMsg)
|
||||
}
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
log := xrayLog.String()
|
||||
wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
|
||||
if !strings.Contains(log, wantUp) {
|
||||
t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log)
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
|
||||
// a relay-reachable test destination.
|
||||
func firstNonLoopbackIPv4() (netip.Addr, bool) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipNet, ok := a.(*net.IPNet)
|
||||
if !ok || ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if v4 := ipNet.IP.To4(); v4 != nil {
|
||||
addr, ok := netip.AddrFromSlice(v4)
|
||||
if ok {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
func startTCPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
|
||||
if err != nil {
|
||||
t.Fatalf("start TCP echo listener: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := c.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := c.Write(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
return ln, netip.AddrPortFrom(addr, uint16(port))
|
||||
}
|
||||
|
||||
func startUDPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(addr.String(), "0"))
|
||||
if err != nil {
|
||||
t.Fatalf("start UDP echo listener: %v", err)
|
||||
}
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, raddr, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := pc.WriteTo(buf[:n], raddr); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
return pc, netip.AddrPortFrom(addr, uint16(port))
|
||||
}
|
||||
|
||||
// readFull reads exactly len(buf) bytes or fails after timeout, since
|
||||
// gonet.TCPConn (and net.Conn generally) may return short reads.
|
||||
func readFull(r interface{ Read([]byte) (int, error) }, buf []byte, timeout time.Duration) (int, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
total := 0
|
||||
for total < len(buf) {
|
||||
if time.Now().After(deadline) {
|
||||
return total, fmt.Errorf("timed out after reading %d/%d bytes", total, len(buf))
|
||||
}
|
||||
n, err := r.Read(buf[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// syncBuffer is a concurrency-safe bytes buffer for capturing a subprocess's
|
||||
// combined stdout/stderr while the test may read it from another goroutine.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buf.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buf.String()
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func waitForPort(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("xray port %d did not open in time", port)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's
|
||||
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings). Its own
|
||||
// range, distinct from amneziawg.EgressBasePort (63100, the kernel-module
|
||||
// path's TPROXY bridge port) so the two can never collide even if both
|
||||
// happen to be reachable during a transition.
|
||||
const SOCKSBasePort = 65100
|
||||
|
||||
// SOCKSPortForInbound returns the loopback port of one AmneziaWG inbound's
|
||||
// own Xray SOCKS5 relay inbound, derived deterministically from its id so
|
||||
// the config-generation code (which builds the inbound) and the relay code
|
||||
// (which dials it) never have to agree on a runtime-negotiated value --
|
||||
// mirrors amneziawg.EgressPortForInbound's own reasoning exactly.
|
||||
func SOCKSPortForInbound(inboundID int) int {
|
||||
return SOCKSBasePort + inboundID
|
||||
}
|
||||
|
||||
var (
|
||||
socksPasswordOnce sync.Once
|
||||
socksPassword string
|
||||
)
|
||||
|
||||
// SocksPassword returns the process-wide password used to authenticate into
|
||||
// every AmneziaWG SOCKS5 relay inbound, generating and caching it once
|
||||
// (lazily, on first use) rather than persisting it anywhere: this traffic
|
||||
// never leaves loopback, both the config generator (SocksInboundSettings'
|
||||
// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same
|
||||
// process, and Xray's own generated config is already rebuilt from scratch
|
||||
// on every reconcile -- there is nothing for a stored value to survive
|
||||
// across that a fresh one wouldn't equally satisfy. Not a real secret (see
|
||||
// SocksRelay's own doc comment); this only needs to be unpredictable enough
|
||||
// that nothing outside this process could plausibly guess it and dial in
|
||||
// over loopback.
|
||||
func SocksPassword() string {
|
||||
socksPasswordOnce.Do(func() {
|
||||
var b [24]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand failing is effectively unrecoverable for a
|
||||
// process that generates real WireGuard keys elsewhere too;
|
||||
// a fixed fallback keeps this from panicking outright.
|
||||
socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b)
|
||||
return
|
||||
}
|
||||
socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
|
||||
})
|
||||
return socksPassword
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/buffer"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
||||
)
|
||||
|
||||
// UDPHandler is called for every UDP packet a tunnel client sends, with its
|
||||
// source (the peer's tunnel-internal address) and its real,
|
||||
// dynamically-arbitrary destination -- recovered the same way the TCP
|
||||
// forwarder recovers its destination, from the packet's own transport
|
||||
// endpoint ID, never from a preconfigured table. The handler owns all flow
|
||||
// tracking and reply delivery (via WriteUDPReply): gVisor has no
|
||||
// udp.NewForwarder the way it does for TCP, so unlike AttachTCPForwarder
|
||||
// this can't just hand back a ready net.Conn.
|
||||
type UDPHandler func(src, dst netip.AddrPort, payload []byte)
|
||||
|
||||
// AttachUDPHandler attaches a raw UDP handler to gstack, independently
|
||||
// enabling the same promiscuous+spoofing mode AttachTCPForwarder needs --
|
||||
// safe and idempotent to call regardless of whether AttachTCPForwarder was
|
||||
// attached to the same stack first, or at all. Adapted from xtls/xray-core's
|
||||
// proxy/wireguard/tun.go UDP path (MIT), which hand-tracks flows for the
|
||||
// identical reason: gVisor doesn't provide a UDP forwarder.
|
||||
func AttachUDPHandler(gstack *stack.Stack, handler UDPHandler) {
|
||||
enablePromiscuousRouting(gstack)
|
||||
|
||||
gstack.SetTransportProtocolHandler(udp.ProtocolNumber, func(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
|
||||
data := pkt.Clone().Data().AsRange().ToSlice()
|
||||
src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
|
||||
dst := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
|
||||
handler(src, dst, data)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// WriteUDPReply injects a UDP packet into gstack as if it arrived from
|
||||
// `from` addressed to `to` -- i.e. a reply travelling back into the tunnel
|
||||
// toward the client -- constructed by hand since gVisor exposes no
|
||||
// connected-socket-style Write for an address the stack doesn't itself own.
|
||||
func WriteUDPReply(gstack *stack.Stack, from, to netip.AddrPort, payload []byte) error {
|
||||
udpLen := header.UDPMinimumSize + len(payload)
|
||||
srcIP := tcpip.AddrFromSlice(from.Addr().AsSlice())
|
||||
dstIP := tcpip.AddrFromSlice(to.Addr().AsSlice())
|
||||
|
||||
isIPv4 := from.Addr().Is4()
|
||||
ipHdrSize := header.IPv6MinimumSize
|
||||
ipProtocol := header.IPv6ProtocolNumber
|
||||
if isIPv4 {
|
||||
ipHdrSize = header.IPv4MinimumSize
|
||||
ipProtocol = header.IPv4ProtocolNumber
|
||||
}
|
||||
|
||||
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
|
||||
ReserveHeaderBytes: ipHdrSize + header.UDPMinimumSize,
|
||||
Payload: buffer.MakeWithData(payload),
|
||||
})
|
||||
defer pkt.DecRef()
|
||||
|
||||
udpHdr := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize))
|
||||
udpHdr.Encode(&header.UDPFields{
|
||||
SrcPort: from.Port(),
|
||||
DstPort: to.Port(),
|
||||
Length: uint16(udpLen),
|
||||
})
|
||||
xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcIP, dstIP, uint16(udpLen))
|
||||
udpHdr.SetChecksum(^udpHdr.CalculateChecksum(checksum.Checksum(payload, xsum)))
|
||||
|
||||
if isIPv4 {
|
||||
ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize))
|
||||
ipHdr.Encode(&header.IPv4Fields{
|
||||
TotalLength: uint16(header.IPv4MinimumSize + udpLen),
|
||||
TTL: 64,
|
||||
Protocol: uint8(header.UDPProtocolNumber),
|
||||
SrcAddr: srcIP,
|
||||
DstAddr: dstIP,
|
||||
})
|
||||
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
|
||||
} else {
|
||||
ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize))
|
||||
ipHdr.Encode(&header.IPv6Fields{
|
||||
PayloadLength: uint16(udpLen),
|
||||
TransportProtocol: header.UDPProtocolNumber,
|
||||
HopLimit: 64,
|
||||
SrcAddr: srcIP,
|
||||
DstAddr: dstIP,
|
||||
})
|
||||
}
|
||||
|
||||
if tcpipErr := gstack.WriteRawPacket(1, ipProtocol, buffer.MakeWithView(pkt.ToView())); tcpipErr != nil {
|
||||
return fmt.Errorf("amneziawgnet: WriteRawPacket: %s", tcpipErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// TestNewDeviceUDPHandlerAndReply is the UDP counterpart of
|
||||
// TestNewDeviceHandshakeForwarderAndIdentity: this package's own udp.go was
|
||||
// refactored from the Phase 0 spike's bake-the-dial-in version to a generic
|
||||
// handler-plus-reply-injection design (see AttachUDPHandler/WriteUDPReply's
|
||||
// doc comments), a real behavior change worth its own verification rather
|
||||
// than assuming the port preserved correctness -- UDP was flagged as "the
|
||||
// harder half" in the migration plan's own risk list, precisely because
|
||||
// gVisor has no udp.NewForwarder and the reply path has to be constructed
|
||||
// by hand.
|
||||
func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
|
||||
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate server keypair: %v", err)
|
||||
}
|
||||
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate client keypair: %v", err)
|
||||
}
|
||||
|
||||
const listenPort = 58713 // distinct from the TCP test's port
|
||||
const wantEmail = "udp-test-peer@example.com"
|
||||
const echoPayload = "hello-from-client"
|
||||
|
||||
inst := amneziawg.Instance{
|
||||
Id: 2,
|
||||
InterfaceName: "awgtest2",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{"10.202.0.1/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: amneziawg.Obfuscation20{
|
||||
Jc: 4, Jmin: 40, Jmax: 70,
|
||||
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||
},
|
||||
Peers: []amneziawg.Peer{{
|
||||
Email: wantEmail,
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{"10.202.0.2/32"},
|
||||
}},
|
||||
}
|
||||
|
||||
dev, err := NewDevice(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer dev.Close()
|
||||
|
||||
idx := NewPeerIndex(inst.Peers)
|
||||
// Never configured anywhere server-side, same idea as the TCP test.
|
||||
wantDest := netip.MustParseAddrPort("10.202.9.9:5353")
|
||||
|
||||
identityErrCh := make(chan error, 8)
|
||||
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
|
||||
if peer, ok := idx.Lookup(src.Addr()); !ok || peer.Email != wantEmail {
|
||||
identityErrCh <- fmt.Errorf("peer identity lookup for src %v: ok=%v email=%q, want %q", src, ok, peer.Email, wantEmail)
|
||||
return
|
||||
}
|
||||
if dst != wantDest {
|
||||
identityErrCh <- fmt.Errorf("recovered dest = %v, want %v", dst, wantDest)
|
||||
return
|
||||
}
|
||||
// Echo the payload back, posing as a reply from the destination the
|
||||
// client dialed -- exactly what a real relay's downstream reply
|
||||
// would look like from the tunnel's point of view.
|
||||
if err := WriteUDPReply(dev.Stack, dst, src, payload); err != nil {
|
||||
identityErrCh <- fmt.Errorf("WriteUDPReply: %w", err)
|
||||
}
|
||||
})
|
||||
|
||||
clientTun, clientNet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
|
||||
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
|
||||
if err != nil {
|
||||
t.Fatalf("client CreateNetTUN: %v", err)
|
||||
}
|
||||
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||
defer clientDev.Close()
|
||||
|
||||
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("client key to hex: %v", err)
|
||||
}
|
||||
serverPubHex, err := wireguard.KeyToHex(serverPub)
|
||||
if err != nil {
|
||||
t.Fatalf("server key to hex: %v", err)
|
||||
}
|
||||
clientConf := fmt.Sprintf(
|
||||
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
|
||||
clientPrivHex, serverPubHex, listenPort)
|
||||
if err := clientDev.IpcSet(clientConf); err != nil {
|
||||
t.Fatalf("client IpcSet: %v", err)
|
||||
}
|
||||
if err := clientDev.Up(); err != nil {
|
||||
t.Fatalf("client Up: %v", err)
|
||||
}
|
||||
|
||||
conn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, wantDest)
|
||||
if err != nil {
|
||||
t.Fatalf("client DialUDPAddrPort: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var buf [256]byte
|
||||
for {
|
||||
select {
|
||||
case err := <-identityErrCh:
|
||||
t.Fatal(err)
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if _, err := conn.Write([]byte(echoPayload)); err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("client write never succeeded: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
n, err := conn.Read(buf[:])
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("client never received a reply: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got := string(buf[:n]); got != echoPayload {
|
||||
t.Fatalf("echoed payload = %q, want %q", got, echoPayload)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Phase 3.5: restoring each opted-in peer's distinct public IPv6 source
|
||||
// identity for peer-initiated outbound connections. The retired
|
||||
// kernel-module architecture used NDP-proxying (ip -6 neigh add proxy) to
|
||||
// hand inbound traffic off to a real awg<N> kernel interface — this path has
|
||||
// no such interface at all (the tunnel lives entirely inside an in-process
|
||||
// gVisor netstack), so there is nothing for NDP-proxying to forward into.
|
||||
// Scoped to what this path actually needs — a peer's own outbound
|
||||
// connections carrying a distinct source address, not unsolicited inbound
|
||||
// connections toward the peer (that's the separate, not-yet-built Phase
|
||||
// 3.6 port-forwarding) — a host-owned address alias is sufficient and
|
||||
// simpler: once the kernel genuinely owns the address, Xray's freedom
|
||||
// outbound can bind an egress socket to it, and return traffic lands on a
|
||||
// normal, locally-owned address with no forwarding or NDP-proxy involved.
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// v6Alias is one host-owned IPv6 address alias this package manages, always
|
||||
// applied as a /128 regardless of whatever prefix width the peer's own
|
||||
// AllowedIPs entry happens to use.
|
||||
type v6Alias struct {
|
||||
Addr string
|
||||
Iface string
|
||||
}
|
||||
|
||||
// effectiveIPv6ExternalInterface returns IPv6ExternalInterface if the admin
|
||||
// set one, falling back to ExternalInterface — matches the frontend's own
|
||||
// ipv6ExternalInterfaceHint copy ("Leave empty to reuse External
|
||||
// Interface") and the retired kernel-module PostUp's identical fallback.
|
||||
func effectiveIPv6ExternalInterface(inst amneziawg.Instance) string {
|
||||
if inst.IPv6ExternalInterface != "" {
|
||||
return inst.IPv6ExternalInterface
|
||||
}
|
||||
return inst.ExternalInterface
|
||||
}
|
||||
|
||||
// desiredV6Aliases returns the aliases inst wants right now, keyed by peer
|
||||
// email. Empty whenever inst isn't fully configured for this feature
|
||||
// (IPv6Enabled false, or no usable interface either way) — deliberately
|
||||
// what makes "IPv6 toggled off" fall out of diffV6Aliases for free, rather
|
||||
// than a separate branch anywhere else.
|
||||
func desiredV6Aliases(inst amneziawg.Instance) map[string]v6Alias {
|
||||
out := map[string]v6Alias{}
|
||||
if !inst.IPv6Enabled {
|
||||
return out
|
||||
}
|
||||
iface := effectiveIPv6ExternalInterface(inst)
|
||||
if iface == "" {
|
||||
return out
|
||||
}
|
||||
for _, p := range inst.Peers {
|
||||
if p.Email == "" {
|
||||
continue
|
||||
}
|
||||
if addr := amneziawg.FirstIPv6(p.AllowedIPs); addr != "" {
|
||||
out[p.Email] = v6Alias{Addr: addr, Iface: iface}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diffV6Aliases returns the ip -6 addr add/del calls needed to move the
|
||||
// host from oldInst's alias set to newInst's. Pass amneziawg.Instance{} as
|
||||
// oldInst for "nothing was aliased before" (a brand new instance) and as
|
||||
// newInst for "tear down entirely" (Remove/StopAll/Reconcile's stop-loop).
|
||||
// A peer whose alias is unchanged appears in neither slice — the common
|
||||
// case on every steady-state reconcile tick, so a healthy system issues no
|
||||
// exec calls at all most of the time.
|
||||
func diffV6Aliases(oldInst, newInst amneziawg.Instance) (add, remove []v6Alias) {
|
||||
oldSet, newSet := desiredV6Aliases(oldInst), desiredV6Aliases(newInst)
|
||||
for email, oldAlias := range oldSet {
|
||||
if newAlias, ok := newSet[email]; ok && newAlias == oldAlias {
|
||||
continue
|
||||
}
|
||||
remove = append(remove, oldAlias)
|
||||
}
|
||||
for email, newAlias := range newSet {
|
||||
if oldAlias, ok := oldSet[email]; ok && oldAlias == newAlias {
|
||||
continue
|
||||
}
|
||||
add = append(add, newAlias)
|
||||
}
|
||||
return add, remove
|
||||
}
|
||||
|
||||
// runIP is the seam tests swap to assert exact invocations without a real
|
||||
// ip binary — this package has no internal/database dependency, so
|
||||
// everything except this var's real invocation builds and unit-tests fine
|
||||
// even on a non-Linux dev machine; the real command is verified manually
|
||||
// against a Linux VPS, matching this project's established verification
|
||||
// pattern for other OS-effecting AmneziaWG changes.
|
||||
var runIP = func(ctx context.Context, args ...string) (stderr string, err error) {
|
||||
cmd := exec.CommandContext(ctx, "ip", args...)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
err = cmd.Run()
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
const ipCommandTimeout = 3 * time.Second
|
||||
|
||||
// applyV6Aliases runs every add before any remove, so a peer whose address
|
||||
// changed is never briefly unaliased (briefly having both old and new
|
||||
// aliased at once is harmless). Never surfaces an error — an alias failing
|
||||
// only narrows that one peer's own outbound-source-identity feature, never
|
||||
// a reason to fail the tunnel or its SOCKS5 relay.
|
||||
func applyV6Aliases(add, remove []v6Alias) {
|
||||
for _, a := range add {
|
||||
addV6Alias(a)
|
||||
}
|
||||
for _, a := range remove {
|
||||
removeV6Alias(a)
|
||||
}
|
||||
}
|
||||
|
||||
func addV6Alias(a v6Alias) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
|
||||
defer cancel()
|
||||
// nodad: this address is a specific peer's own admin-assigned identity,
|
||||
// nothing else on the link should ever claim it, so the ~1s Duplicate
|
||||
// Address Detection window before the kernel would otherwise mark it
|
||||
// usable is pure latency with no real collision to detect.
|
||||
stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
|
||||
if err == nil {
|
||||
logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
|
||||
return
|
||||
}
|
||||
if strings.Contains(stderr, "File exists") {
|
||||
// Already the desired end state -- most commonly hit once, harmlessly,
|
||||
// right after an ungraceful panel restart (the OS-level alias from
|
||||
// before the crash outlives the process; the in-memory managed map
|
||||
// doesn't).
|
||||
return
|
||||
}
|
||||
logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
|
||||
func removeV6Alias(a v6Alias) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
|
||||
defer cancel()
|
||||
stderr, err := runIP(ctx, "-6", "addr", "del", a.Addr+"/128", "dev", a.Iface)
|
||||
if err == nil {
|
||||
logger.Infof("amneziawgnet: removed IPv6 alias %s from %s", a.Addr, a.Iface)
|
||||
return
|
||||
}
|
||||
if strings.Contains(stderr, "Cannot assign requested address") || strings.Contains(stderr, "Cannot find device") {
|
||||
// Already gone (the address itself, or the whole interface) -- for a
|
||||
// delete, the desired end state ("not aliased here") already holds.
|
||||
return
|
||||
}
|
||||
logger.Warningf("amneziawgnet: remove IPv6 alias %s from %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
)
|
||||
|
||||
func peerWithIPs(email string, ips ...string) amneziawg.Peer {
|
||||
return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips}
|
||||
}
|
||||
|
||||
func instV6(enabled bool, extIface, v6ExtIface string, peers ...amneziawg.Peer) amneziawg.Instance {
|
||||
return amneziawg.Instance{
|
||||
Id: 1,
|
||||
IPv6Enabled: enabled,
|
||||
ExternalInterface: extIface,
|
||||
IPv6ExternalInterface: v6ExtIface,
|
||||
Peers: peers,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredV6AliasesDisabledOrNoInterfaceReturnsEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
inst amneziawg.Instance
|
||||
}{
|
||||
{"IPv6Enabled false", instV6(false, "", "eth0", peerWithIPs("a@x", "fd86::2/128"))},
|
||||
{"no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128"))},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := desiredV6Aliases(c.inst); len(got) != 0 {
|
||||
t.Errorf("%s: desiredV6Aliases = %v, want empty", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredV6AliasesFallsBackToExternalInterface(t *testing.T) {
|
||||
inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
|
||||
got := desiredV6Aliases(inst)
|
||||
if got["a@x"].Iface != "eth0" {
|
||||
t.Fatalf("expected fallback to ExternalInterface eth0, got %+v", got)
|
||||
}
|
||||
|
||||
inst2 := instV6(true, "eth0", "eth1", peerWithIPs("a@x", "fd86::2/128"))
|
||||
got2 := desiredV6Aliases(inst2)
|
||||
if got2["a@x"].Iface != "eth1" {
|
||||
t.Fatalf("expected IPv6ExternalInterface eth1 to win over ExternalInterface, got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredV6AliasesSkipsPeersWithoutEmailOrV6Address(t *testing.T) {
|
||||
inst := instV6(true, "eth0", "",
|
||||
peerWithIPs("", "fd86::2/128"), // no email
|
||||
peerWithIPs("b@x", "10.8.1.2/32"), // v4 only, no v6
|
||||
peerWithIPs("c@x", "fd86::3/128"), // qualifies
|
||||
)
|
||||
got := desiredV6Aliases(inst)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("desiredV6Aliases = %+v, want exactly one entry (c@x)", got)
|
||||
}
|
||||
if _, ok := got["c@x"]; !ok {
|
||||
t.Fatalf("desiredV6Aliases = %+v, want c@x present", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesNoOpWhenUnchanged(t *testing.T) {
|
||||
inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
|
||||
add, remove := diffV6Aliases(inst, inst)
|
||||
if len(add) != 0 || len(remove) != 0 {
|
||||
t.Fatalf("expected no-op for an unchanged instance, got add=%v remove=%v", add, remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesBrandNewInstanceIsAddOnly(t *testing.T) {
|
||||
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
|
||||
add, remove := diffV6Aliases(amneziawg.Instance{}, newInst)
|
||||
if len(remove) != 0 {
|
||||
t.Fatalf("expected no removals for a brand new instance, got %v", remove)
|
||||
}
|
||||
if len(add) != 2 {
|
||||
t.Fatalf("expected both peers added, got %v", add)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesTornDownInstanceIsRemoveOnly(t *testing.T) {
|
||||
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
|
||||
add, remove := diffV6Aliases(oldInst, amneziawg.Instance{})
|
||||
if len(add) != 0 {
|
||||
t.Fatalf("expected no adds when tearing down, got %v", add)
|
||||
}
|
||||
if len(remove) != 2 {
|
||||
t.Fatalf("expected both peers removed, got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesIPv6EnabledToggledOffRemovesAllAddsNone(t *testing.T) {
|
||||
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
|
||||
newInst := instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // same peers, feature disabled
|
||||
add, remove := diffV6Aliases(oldInst, newInst)
|
||||
if len(add) != 0 {
|
||||
t.Fatalf("expected no adds when IPv6Enabled is toggled off, got %v", add)
|
||||
}
|
||||
if len(remove) != 1 {
|
||||
t.Fatalf("expected the previously-aliased peer removed, got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesAddressChangeForSamePeerIsRemoveOldAddNew(t *testing.T) {
|
||||
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
|
||||
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::99/128"))
|
||||
add, remove := diffV6Aliases(oldInst, newInst)
|
||||
if len(add) != 1 || add[0].Addr != "fd86::99" {
|
||||
t.Fatalf("expected new address added, got %v", add)
|
||||
}
|
||||
if len(remove) != 1 || remove[0].Addr != "fd86::2" {
|
||||
t.Fatalf("expected old address removed, got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesInterfaceChangeReAliasesUnchangedPeers(t *testing.T) {
|
||||
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
|
||||
newInst := instV6(true, "eth1", "", peerWithIPs("a@x", "fd86::2/128")) // same address, interface moved
|
||||
add, remove := diffV6Aliases(oldInst, newInst)
|
||||
if len(add) != 1 || add[0].Iface != "eth1" {
|
||||
t.Fatalf("expected re-add on the new interface, got %v", add)
|
||||
}
|
||||
if len(remove) != 1 || remove[0].Iface != "eth0" {
|
||||
t.Fatalf("expected removal from the old interface, got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffV6AliasesPeerRemovedFromInstanceIsRemoveOnly(t *testing.T) {
|
||||
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
|
||||
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // b@x removed
|
||||
add, remove := diffV6Aliases(oldInst, newInst)
|
||||
if len(add) != 0 {
|
||||
t.Fatalf("expected no adds, got %v", add)
|
||||
}
|
||||
if len(remove) != 1 || remove[0].Addr != "fd86::3" {
|
||||
t.Fatalf("expected only b@x's address removed, got %v", remove)
|
||||
}
|
||||
}
|
||||
|
||||
// --- exec-layer tests: swap runIP, never invoke a real ip binary ---
|
||||
|
||||
func withFakeRunIP(t *testing.T, fn func(ctx context.Context, args ...string) (string, error)) *[][]string {
|
||||
t.Helper()
|
||||
var calls [][]string
|
||||
orig := runIP
|
||||
runIP = func(ctx context.Context, args ...string) (string, error) {
|
||||
calls = append(calls, append([]string(nil), args...))
|
||||
return fn(ctx, args...)
|
||||
}
|
||||
t.Cleanup(func() { runIP = orig })
|
||||
return &calls
|
||||
}
|
||||
|
||||
func TestAddV6AliasPassesExpectedArgs(t *testing.T) {
|
||||
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "", nil
|
||||
})
|
||||
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
if len(*calls) != 1 {
|
||||
t.Fatalf("expected exactly one runIP call, got %d", len(*calls))
|
||||
}
|
||||
want := []string{"-6", "addr", "add", "fd86::2/128", "dev", "eth0", "nodad"}
|
||||
got := (*calls)[0]
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddV6AliasFileExistsIsSwallowed(t *testing.T) {
|
||||
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "RTNETLINK answers: File exists", errors.New("exit status 2")
|
||||
})
|
||||
// Must not panic and must return normally -- there is nothing else to
|
||||
// assert on since addV6Alias has no return value, matching this
|
||||
// codebase's existing best-effort exec-call conventions (no test in
|
||||
// this repo asserts on logger output for a swallowed vs. warned
|
||||
// classification; see internal/web/service/server.go's own untested
|
||||
// exec.CommandContext call sites).
|
||||
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
}
|
||||
|
||||
func TestAddV6AliasOtherFailureDoesNotPanic(t *testing.T) {
|
||||
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "RTNETLINK answers: Cannot find device \"eth9\"", errors.New("exit status 1")
|
||||
})
|
||||
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth9"})
|
||||
}
|
||||
|
||||
func TestRemoveV6AliasPassesExpectedArgs(t *testing.T) {
|
||||
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "", nil
|
||||
})
|
||||
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
want := []string{"-6", "addr", "del", "fd86::2/128", "dev", "eth0"}
|
||||
got := (*calls)[0]
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveV6AliasAddressAlreadyGoneIsSwallowed(t *testing.T) {
|
||||
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "RTNETLINK answers: Cannot assign requested address", errors.New("exit status 2")
|
||||
})
|
||||
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
}
|
||||
|
||||
func TestRemoveV6AliasDeviceAlreadyGoneIsSwallowed(t *testing.T) {
|
||||
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "Cannot find device \"eth0\"", errors.New("exit status 1")
|
||||
})
|
||||
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
}
|
||||
|
||||
func TestRemoveV6AliasOtherFailureDoesNotPanic(t *testing.T) {
|
||||
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
return "some unrelated failure", errors.New("exit status 1")
|
||||
})
|
||||
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
|
||||
}
|
||||
|
||||
func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
|
||||
var order []string
|
||||
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
|
||||
if args[2] == "add" {
|
||||
order = append(order, "add")
|
||||
} else {
|
||||
order = append(order, "del")
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
applyV6Aliases(
|
||||
[]v6Alias{{Addr: "fd86::99", Iface: "eth0"}},
|
||||
[]v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
|
||||
)
|
||||
if len(*calls) != 2 {
|
||||
t.Fatalf("expected exactly 2 calls, got %d", len(*calls))
|
||||
}
|
||||
if order[0] != "add" || order[1] != "del" {
|
||||
t.Fatalf("expected add before del, got order=%v", order)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// AmneziaWGJob reconciles the running AmneziaWG interfaces against the
|
||||
// enabled AmneziaWG inbounds in the database, restarts/reloads any that
|
||||
// drifted, and folds the per-peer traffic scraped from `awg show dump` into
|
||||
// the usual client and inbound traffic accounting. Mirrors MtprotoJob.
|
||||
// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
|
||||
// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
|
||||
// module) against the enabled AmneziaWG inbounds in the database,
|
||||
// rebuilding/reconfiguring any that drifted. Unlike the retired
|
||||
// kernel-module Manager this job used to drive, there is no traffic/
|
||||
// online-status accounting here at all: once a peer's decapsulated traffic
|
||||
// is relayed into Xray's own SOCKS5 inbound (see
|
||||
// internal/web/service/xray.go's injectAmneziawgnetSocks, and
|
||||
// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
|
||||
// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
|
||||
// stats/online-status polling already picks it up for free.
|
||||
type AmneziaWGJob struct {
|
||||
inboundService service.InboundService
|
||||
// warnedMissing tracks whether the "awg/awg-quick not found" warning has
|
||||
// already been logged, so a host without the AmneziaWG kernel module
|
||||
// (RHEL, Arch, a container, or a failed install.sh PPA step) logs it
|
||||
// once instead of every @every-10s tick forever.
|
||||
warnedMissing bool
|
||||
}
|
||||
|
||||
// NewAmneziaWGJob creates a new AmneziaWG reconcile/traffic job instance.
|
||||
// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
|
||||
func NewAmneziaWGJob() *AmneziaWGJob {
|
||||
return new(AmneziaWGJob)
|
||||
}
|
||||
|
||||
// Run reconciles desired AmneziaWG inbounds with running interfaces and
|
||||
// records per-peer traffic deltas and online status.
|
||||
// Run reconciles desired AmneziaWG inbounds with running embedded interfaces.
|
||||
func (j *AmneziaWGJob) Run() {
|
||||
desired, err := j.inboundService.DesiredAmneziaWGInstances()
|
||||
if err != nil {
|
||||
@@ -35,59 +34,9 @@ func (j *AmneziaWGJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only relevant once an admin actually has an AmneziaWG inbound: no
|
||||
// point warning about a missing binary the panel never needed to touch.
|
||||
if len(desired) > 0 && !amneziawg.IsAwgInstalled() {
|
||||
if !j.warnedMissing {
|
||||
j.warnedMissing = true
|
||||
logger.Warningf("amneziawg job: %d AmneziaWG inbound(s) configured but awg/awg-quick not found on PATH; skipping reconcile until installed", len(desired))
|
||||
}
|
||||
return
|
||||
}
|
||||
j.warnedMissing = false
|
||||
|
||||
activeTags := make([]string, 0, len(desired))
|
||||
wanted := make([]amneziawgnet.Desired, 0, len(desired))
|
||||
for _, inst := range desired {
|
||||
activeTags = append(activeTags, inst.Tag)
|
||||
wanted = append(wanted, amneziawgnet.Desired{Instance: inst})
|
||||
}
|
||||
|
||||
mgr := amneziawg.GetManager()
|
||||
mgr.Reconcile(desired)
|
||||
|
||||
deltas, onlineEmails := mgr.CollectTraffic()
|
||||
|
||||
clientTraffics := make([]*xray.ClientTraffic, 0, len(deltas))
|
||||
inboundUp := make(map[string]int64)
|
||||
inboundDown := make(map[string]int64)
|
||||
for _, d := range deltas {
|
||||
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
|
||||
Email: d.Email,
|
||||
Up: d.Up,
|
||||
Down: d.Down,
|
||||
})
|
||||
inboundUp[d.Tag] += d.Up
|
||||
inboundDown[d.Tag] += d.Down
|
||||
}
|
||||
|
||||
traffics := make([]*xray.Traffic, 0, len(inboundUp))
|
||||
for tag, up := range inboundUp {
|
||||
traffics = append(traffics, &xray.Traffic{
|
||||
IsInbound: true,
|
||||
Tag: tag,
|
||||
Up: up,
|
||||
Down: inboundDown[tag],
|
||||
})
|
||||
}
|
||||
|
||||
if len(traffics) > 0 || len(clientTraffics) > 0 {
|
||||
if _, _, err := j.inboundService.AddTraffic(traffics, clientTraffics); err != nil {
|
||||
logger.Warning("amneziawg job: add traffic failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Live speed: AmneziaWG never runs inside xray-core, so XrayTrafficJob's
|
||||
// own 5s broadcast never mentions these tags. See sidecar_traffic.go.
|
||||
broadcastSidecarTraffic(string(model.AmneziaWG), traffics, clientTraffics)
|
||||
|
||||
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
|
||||
amneziawgnet.GetManager().Reconcile(wanted)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -59,7 +60,7 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return amneziawg.GetManager().Ensure(inst)
|
||||
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{Instance: inst})
|
||||
}
|
||||
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
|
||||
if err != nil {
|
||||
@@ -76,7 +77,7 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
|
||||
return nil
|
||||
}
|
||||
if ib.Protocol == model.AmneziaWG {
|
||||
amneziawg.GetManager().Remove(ib.Id)
|
||||
amneziawgnet.GetManager().Remove(ib.Id)
|
||||
return nil
|
||||
}
|
||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
||||
@@ -130,11 +131,12 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In
|
||||
// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
|
||||
// Remove+Ensure sequence a plain Del+Add would force so that, on an
|
||||
// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
|
||||
// can pick a peers-only `syncconf` instead of always bouncing the interface
|
||||
// (see internal/amneziawg.Manager.ensureLocked).
|
||||
// can reconfigure the running embedded Device in place via IpcSet instead
|
||||
// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
|
||||
// only an address/MTU change forces a rebuild there, not a peer edit).
|
||||
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
|
||||
amneziawg.GetManager().Remove(oldIb.Id)
|
||||
amneziawgnet.GetManager().Remove(oldIb.Id)
|
||||
if !newIb.Enable {
|
||||
return nil
|
||||
}
|
||||
@@ -144,15 +146,15 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
|
||||
_ = l.DelInbound(ctx, oldIb)
|
||||
}
|
||||
if !newIb.Enable {
|
||||
amneziawg.GetManager().Remove(newIb.Id)
|
||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
||||
return nil
|
||||
}
|
||||
inst, ok := amneziawg.InstanceFromInbound(newIb)
|
||||
if !ok {
|
||||
amneziawg.GetManager().Remove(newIb.Id)
|
||||
amneziawgnet.GetManager().Remove(newIb.Id)
|
||||
return nil
|
||||
}
|
||||
return amneziawg.GetManager().Ensure(inst)
|
||||
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{Instance: inst})
|
||||
}
|
||||
|
||||
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
|
||||
|
||||
@@ -57,7 +57,7 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
|
||||
// (frontend/src/pages/inbounds/form/InboundFormModal.tsx), which hides the
|
||||
// "Deploy To" node picker for anything not in this set. MTProto and
|
||||
// AmneziaWG are both sidecar-managed rather than plain Xray inbounds, and
|
||||
// their reconcile loops (mtproto.Manager, amneziawg.Manager) only ever
|
||||
// their reconcile loops (mtproto.Manager, amneziawgnet.Manager) only ever
|
||||
// query for NodeID IS NULL rows -- a node-assigned instance of either would
|
||||
// never be reconciled by the master, yet nothing previously stopped one
|
||||
// from being created that way (the frontend allowlist has no server-side
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
@@ -177,15 +178,15 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
|
||||
}
|
||||
|
||||
// Every enabled local AmneziaWG inbound gets its own automatic Xray
|
||||
// bridge (see injectAmneziawgEgress) on 127.0.0.1 at a port derived
|
||||
// purely from its id (amneziawg.EgressPortForInbound) -- like the
|
||||
// internal Xray API inbound above, that bridge is not itself a database
|
||||
// row, so the ordinary DB-backed query below can never see it. Without
|
||||
// this check, an unrelated inbound saved onto that exact port silently
|
||||
// fails at the next Xray start, taking every other protocol down with
|
||||
// it, not just AmneziaWG.
|
||||
// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
|
||||
// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
|
||||
// like the internal Xray API inbound above, that relay inbound is not
|
||||
// itself a database row, so the ordinary DB-backed query below can never
|
||||
// see it. Without this check, an unrelated inbound saved onto that exact
|
||||
// port silently fails at the next Xray start, taking every other
|
||||
// protocol down with it, not just AmneziaWG.
|
||||
if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) {
|
||||
conflict, err := s.checkAmneziawgEgressConflict(inbound, ignoreId, newBits)
|
||||
conflict, err := s.checkAmneziawgnetSocksConflict(inbound, ignoreId, newBits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,15 +230,16 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// checkAmneziawgEgressConflict reports whether inbound's own port collides
|
||||
// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge
|
||||
// port. Only inbounds that actually have RouteThroughXray on ever get a
|
||||
// bridge (see injectAmneziawgEgress); the others' "reserved" port isn't
|
||||
// really reserved, so they must not be flagged. ignoreId excludes one
|
||||
// inbound id from the AmneziaWG candidates, the same way the general
|
||||
// DB-backed conflict query above excludes the inbound being edited from
|
||||
// matching itself.
|
||||
func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
|
||||
// checkAmneziawgnetSocksConflict reports whether inbound's own port
|
||||
// collides with an existing, enabled local AmneziaWG inbound's automatic
|
||||
// Xray SOCKS5 relay port. Unlike the retired kernel-module bridge this
|
||||
// checks every qualifying AmneziaWG inbound unconditionally: the embedded
|
||||
// relay has no RouteThroughXray-style opt-in, every one of them gets a
|
||||
// relay inbound (see injectAmneziawgnetSocks). ignoreId excludes one inbound
|
||||
// id from the AmneziaWG candidates, the same way the general DB-backed
|
||||
// conflict query above excludes the inbound being edited from matching
|
||||
// itself.
|
||||
func (s *InboundService) checkAmneziawgnetSocksConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
|
||||
db := database.GetDB()
|
||||
var candidates []*model.Inbound
|
||||
q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true)
|
||||
@@ -248,11 +250,10 @@ func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ig
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
inst, ok := amneziawg.InstanceFromInbound(c)
|
||||
if !ok || !inst.RouteThroughXray {
|
||||
if _, ok := amneziawg.InstanceFromInbound(c); !ok {
|
||||
continue
|
||||
}
|
||||
if amneziawg.EgressPortForInbound(c.Id) != inbound.Port {
|
||||
if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
|
||||
continue
|
||||
}
|
||||
return &portConflictDetail{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -732,16 +732,18 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
|
||||
}
|
||||
|
||||
// amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings
|
||||
// blob with one qualifying, enabled peer and RouteThroughXray on -- the
|
||||
// shape that actually makes injectAmneziawgEgress (and therefore
|
||||
// checkAmneziawgEgressConflict) create a bridge at all.
|
||||
// blob with one qualifying, enabled peer -- the shape that makes
|
||||
// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict)
|
||||
// create a relay inbound at all. The routeThroughXray field is kept in the
|
||||
// JSON (a stale value from a pre-cutover install) specifically to prove
|
||||
// it's now ignored -- see the "RouteThroughXrayOff" test below.
|
||||
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
|
||||
|
||||
// An enabled AmneziaWG inbound's automatic Xray bridge (injectAmneziawgEgress)
|
||||
// is a synthetic loopback dokodemo-door inbound, not a database row, so
|
||||
// checkPortConflict needs its own check to catch a collision -- exactly the
|
||||
// same shape of problem as the reserved API port above.
|
||||
func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
|
||||
// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
|
||||
// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
|
||||
// row, so checkPortConflict needs its own check to catch a collision --
|
||||
// exactly the same shape of problem as the reserved API port above.
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
||||
|
||||
@@ -749,13 +751,13 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
|
||||
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id)
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: bridgePort,
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
@@ -763,7 +765,7 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("a local inbound on the AmneziaWG bridge port %d must conflict", bridgePort)
|
||||
t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort)
|
||||
}
|
||||
if msg := got.String(); !strings.Contains(msg, "awg-1") {
|
||||
t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg)
|
||||
@@ -771,9 +773,9 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
|
||||
}
|
||||
|
||||
// Nodes run their own Xray, so a node inbound landing on the central panel's
|
||||
// AmneziaWG bridge port must be allowed -- the bridge only ever binds
|
||||
// AmneziaWG relay port must be allowed -- the relay inbound only ever binds
|
||||
// 127.0.0.1 on the local panel's own Xray.
|
||||
func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) {
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
||||
|
||||
@@ -781,37 +783,37 @@ func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) {
|
||||
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id)
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "node-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: bridgePort,
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
NodeID: new(1),
|
||||
}
|
||||
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
||||
t.Fatalf("a node inbound on the local AmneziaWG bridge port must be allowed; got=%v err=%v", got, err)
|
||||
t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled AmneziaWG inbound never gets a bridge injected
|
||||
// (injectAmneziawgEgress skips !inbound.Enable), so its "reserved" port must
|
||||
// not block anything.
|
||||
func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T) {
|
||||
// A disabled AmneziaWG inbound never gets a relay inbound injected
|
||||
// (injectAmneziawgnetSocks skips !inbound.Enable), so its "reserved" port
|
||||
// must not block anything.
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`}
|
||||
if err := database.GetDB().Create(awg).Error; err != nil {
|
||||
t.Fatalf("seed disabled awg inbound: %v", err)
|
||||
}
|
||||
bridgePort := amneziawg.EgressPortForInbound(awg.Id)
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: bridgePort,
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
||||
@@ -819,11 +821,41 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// An enabled AmneziaWG inbound with RouteThroughXray off never gets a bridge
|
||||
// injected either (injectAmneziawgEgress requires it), so its port isn't
|
||||
// reserved -- an inbound created with the default settings, not just an
|
||||
// explicitly disabled one, must not block anything.
|
||||
func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t *testing.T) {
|
||||
// Unlike the retired kernel-module bridge, the embedded relay has no
|
||||
// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound
|
||||
// reserves its relay port regardless of that (now-vestigial) field's value,
|
||||
// including a stale routeThroughXray:true left over from a pre-cutover
|
||||
// install (amneziawgRoutedSettings).
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`)
|
||||
|
||||
var awgInbound model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default")
|
||||
}
|
||||
}
|
||||
|
||||
// A qualifying AmneziaWG inbound with no enabled/valid peer at all never
|
||||
// gets a relay inbound (amneziawg.InstanceFromInbound returns ok=false), so
|
||||
// its port isn't reserved.
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
|
||||
|
||||
@@ -831,24 +863,24 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t
|
||||
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id)
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: bridgePort,
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
||||
t.Fatalf("an AmneziaWG inbound with RouteThroughXray off must not reserve its bridge port; got=%v err=%v", got, err)
|
||||
t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unrelated port never conflicts with the bridge.
|
||||
func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) {
|
||||
// An unrelated port never conflicts with the relay inbound.
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
@@ -858,6 +890,6 @@ func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
||||
t.Fatalf("an unrelated port must not conflict with the AmneziaWG bridge; got=%v err=%v", got, err)
|
||||
t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
+223
-78
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -367,14 +368,27 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
injectMtprotoEgress(xrayConfig, inbound)
|
||||
}
|
||||
|
||||
// Route opted-in AmneziaWG peers through the core's router. Unlike mtg,
|
||||
// AmneziaWG has no sidecar process of its own making outbound connections
|
||||
// to dial through a bridge — it's a kernel tunnel interface, so the host
|
||||
// side (internal/amneziawg's defaultPostUpDown) TPROXYs each opted-in
|
||||
// peer's traffic to one loopback bridge shared by every AmneziaWG
|
||||
// instance; this call is what creates that bridge and, per peer, the
|
||||
// routing rule matching its preserved source IP to its chosen outbound.
|
||||
injectAmneziawgEgress(xrayConfig, inbounds)
|
||||
// Every AmneziaWG inbound is embedded (internal/amneziawgnet: amneziawg-go
|
||||
// over a gVisor netstack, no kernel module) and relays every peer's
|
||||
// decapsulated traffic into its own loopback SOCKS5 inbound, always on —
|
||||
// unlike mtproto's bridge above, there's no opt-in gate here: once
|
||||
// traffic is decapsulated in gVisor, Xray's own freedom outbound is the
|
||||
// only way it reaches the real internet at all, not an optional extra
|
||||
// hop. Whether it goes anywhere beyond Xray's default routing is up to
|
||||
// whatever rules the admin adds through the stock Routing page, exactly
|
||||
// like routing any other protocol.
|
||||
injectAmneziawgnetSocks(xrayConfig, inbounds)
|
||||
|
||||
// Restores each opted-in peer's own distinct public IPv6 source identity
|
||||
// for its outbound connections, dropped by the hard cutover above (see
|
||||
// Phase 3.5 of the migration plan) — a peer that has an IPv6 address in
|
||||
// its AllowedIPs, on an inbound with IPv6Enabled, gets its own freedom
|
||||
// outbound bound to that exact address via sendThrough.
|
||||
// internal/amneziawgnet's own Manager is responsible for actually
|
||||
// aliasing that address onto the host (see v6alias.go) so the kernel
|
||||
// lets Xray bind an egress socket to it at all; this call only builds
|
||||
// the Xray-side outbound/routing-rule half.
|
||||
injectAmneziawgV6Egress(xrayConfig, inbounds)
|
||||
|
||||
// Wire the panel's own HTTP traffic through the configured outbound, after
|
||||
// the subscription merge so subscription outbound tags are valid targets.
|
||||
@@ -670,61 +684,40 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
|
||||
})
|
||||
}
|
||||
|
||||
// amneziawgEgressDokodemoSettings is the dokodemo-door settings block for the
|
||||
// shared AmneziaWG TPROXY bridge: accept both TCP and UDP, and (per this
|
||||
// fork's existing "Tunnel" protocol convention — see
|
||||
// frontend/src/lib/xray/inbound-tag.ts) use followRedirect mode so the
|
||||
// destination comes from the TPROXY-preserved original address rather than a
|
||||
// fixed port/address pair.
|
||||
const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedirect":true}`
|
||||
|
||||
// amneziawgEgressStreamSettings turns the bridge's listening socket into a
|
||||
// TPROXY target, matching internal/amneziawg's iptables `-j TPROXY` rules —
|
||||
// without this, the kernel-redirected packets never reach a listening
|
||||
// socket.
|
||||
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
|
||||
|
||||
// amneziawgEgressSniffingSettings enables sniffing on the bridge, matching
|
||||
// this fork's own normal per-inbound default (see default.json's "mixed"
|
||||
// inbound). Without this, domain-based Routing rules can never match a
|
||||
// single byte of RouteThroughXray traffic: an AmneziaWG peer resolves DNS
|
||||
// amneziawgEgressSniffingSettings enables sniffing on the AmneziaWG SOCKS5
|
||||
// relay inbound, matching this fork's own normal per-inbound default (see
|
||||
// default.json's "mixed" inbound). Without this, domain-based Routing rules
|
||||
// can never match a single byte of AmneziaWG traffic: a peer resolves DNS
|
||||
// itself, through the tunnel, before ever sending a packet — by the time
|
||||
// TPROXY hands the decapsulated traffic to this bridge, the destination is
|
||||
// already a bare IP, with no domain name attached at the network layer at
|
||||
// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host /
|
||||
// QUIC) the same way it already does for every other inbound; without it,
|
||||
// only tag/IP/network-based rules can ever match this bridge's traffic,
|
||||
// and any domain rule above it in the list is silently unreachable.
|
||||
// the embedded forwarder recovers the decapsulated traffic, the destination
|
||||
// is already a bare IP, with no domain name attached at the network layer
|
||||
// at all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host
|
||||
// / QUIC) the same way it already does for every other inbound; without
|
||||
// it, only tag/IP/network-based rules can ever match this traffic, and any
|
||||
// domain rule above it in the list is silently unreachable.
|
||||
const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
|
||||
|
||||
// injectAmneziawgEgress gives every enabled, RouteThroughXray-opted-in
|
||||
// AmneziaWG inbound with at least one qualifying peer its own loopback
|
||||
// dokodemo-door bridge — tagged with that inbound's own real tag, so it's
|
||||
// already selectable in the panel's stock Routing page's inbound-tag
|
||||
// picker, exactly the way an mtproto inbound's own bridge already is (see
|
||||
// injectMtprotoEgress): the picker's tag list comes from
|
||||
// InboundService.GetInboundTags(), a plain,
|
||||
// protocol-blind SELECT over every inbound row's tag, so reusing a real
|
||||
// inbound's own tag needs no dedicated UI plumbing at all.
|
||||
// injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at
|
||||
// least one qualifying peer its own loopback SOCKS5 inbound for the
|
||||
// embedded (amneziawg-go) relay path (internal/amneziawgnet) -- always on,
|
||||
// unlike injectAmneziawgEgress's opt-in RouteThroughXray bridge above, since
|
||||
// there is no alternative datapath once traffic is decapsulated in gVisor:
|
||||
// Xray's own freedom outbound is how it reaches the real internet at all
|
||||
// (see internal/amneziawgnet/relay.go's doc comment, Finding 3 of the
|
||||
// migration plan). Tagged with the inbound's own real tag, for the same two
|
||||
// reasons injectAmneziawgEgress already is: it's already selectable in the
|
||||
// panel's stock Routing page (InboundService.GetInboundTags is
|
||||
// protocol-blind), and per-inbound traffic totals
|
||||
// (internal/web/service/inbound_traffic.go's addClientTraffic) match by
|
||||
// exact tag -- reusing it isn't a style choice.
|
||||
//
|
||||
// RouteThroughXray is a per-inbound opt-in, off by default: when it's off,
|
||||
// no bridge is created at all and the tunnel has no Xray dependency
|
||||
// whatsoever. When it's on, every peer's traffic lands on the bridge —
|
||||
// internal/amneziawg's defaultPostUpDown TPROXYs it there, there is no
|
||||
// further per-peer opt-in — but this function never generates a routing
|
||||
// rule of its own. Whether that traffic goes anywhere beyond Xray's default
|
||||
// routing is entirely up to whatever rules the admin adds through that same
|
||||
// stock Routing page (inboundTag + an optional sourceIP to target one
|
||||
// specific peer + outboundTag, exactly like routing any other protocol).
|
||||
//
|
||||
// An inbound is skipped, individually, when its own tag is already taken by
|
||||
// another config entry — mirroring injectMtprotoEgress/injectPanelEgress's
|
||||
// own defensive check, even though a real collision shouldn't be possible
|
||||
// (inbound tags are unique, and the main GenXrayInboundConfig loop already
|
||||
// excludes mtproto/amneziawg inbounds from ever claiming their own tag
|
||||
// there). Generated state is hot-appliable and never modifies the stored
|
||||
// template or restarts the core.
|
||||
func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
// No RouteThroughXray gate, no qualifying-peer IPv4 check the way
|
||||
// injectAmneziawgEgress needs one: amneziawg.InstanceFromInbound already
|
||||
// returns ok=false for zero qualifying peers (Enable && PublicKey != "" &&
|
||||
// len(AllowedIPs) > 0), and peer identity here comes from Email directly,
|
||||
// not an IPv4 lookup, so a v6-only peer is just as valid an account as any
|
||||
// other.
|
||||
func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
|
||||
for i := range cfg.InboundConfigs {
|
||||
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
||||
@@ -735,36 +728,188 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
continue
|
||||
}
|
||||
inst, ok := amneziawg.InstanceFromInbound(inbound)
|
||||
if !ok || !inst.RouteThroughXray {
|
||||
continue
|
||||
}
|
||||
hasQualifyingPeer := false
|
||||
for _, p := range inst.Peers {
|
||||
if amneziawg.FirstIPv4(p.AllowedIPs) != "" {
|
||||
hasQualifyingPeer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasQualifyingPeer {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, taken := existingTags[inbound.Tag]; taken {
|
||||
logger.Warning("amneziawg egress: inbound tag [", inbound.Tag, "] already present in generated config, skipping its bridge")
|
||||
logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
|
||||
continue
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(inst.Peers))
|
||||
for _, p := range inst.Peers {
|
||||
if p.Email != "" {
|
||||
emails = append(emails, p.Email)
|
||||
}
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
settings, err := amneziawgnet.SocksInboundSettings(emails, amneziawgnet.SocksPassword())
|
||||
if err != nil {
|
||||
logger.Warning("amneziawgnet socks: building settings for inbound [", inbound.Tag, "]: ", err)
|
||||
continue
|
||||
}
|
||||
|
||||
existingTags[inbound.Tag] = struct{}{}
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: amneziawg.EgressPortForInbound(inbound.Id),
|
||||
Protocol: "dokodemo-door",
|
||||
Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings),
|
||||
StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings),
|
||||
Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings),
|
||||
Tag: inbound.Tag,
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: amneziawgnet.SOCKSPortForInbound(inbound.Id),
|
||||
Protocol: "socks",
|
||||
Settings: json_util.RawMessage(settings),
|
||||
Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings),
|
||||
Tag: inbound.Tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
|
||||
// tag for one peer's IPv6 source-identity egress. Stable across config
|
||||
// regenerations (a pure function of two stable identifiers), so
|
||||
// internal/xray/hot_diff.go's tag-keyed outbound/routing diffing recognizes
|
||||
// "unchanged" rather than remove+recreate on every poll. The inbound.Id
|
||||
// prefix is defense in depth, not load-bearing on its own: email is already
|
||||
// enforced globally unique across the whole panel's client table
|
||||
// (model.ClientRecord.Email has a gorm uniqueIndex) — kept anyway since it
|
||||
// costs nothing and makes the tag self-describing, matching
|
||||
// NodeEgressInboundTag's own style.
|
||||
func amneziawgV6EgressTag(inboundID int, email string) string {
|
||||
return fmt.Sprintf("amneziawg-v6-%d-%s", inboundID, email)
|
||||
}
|
||||
|
||||
// injectAmneziawgV6Egress gives every enabled, non-node-hosted AmneziaWG
|
||||
// peer with an IPv6 AllowedIPs entry its own single-purpose freedom
|
||||
// outbound, bound via sendThrough to that exact address, plus a routing
|
||||
// rule sending only that peer's own traffic through it — restoring the
|
||||
// per-client public IPv6 identity the hard cutover temporarily dropped
|
||||
// (Phase 3.5 of the migration plan). Scoped to outbound source identity
|
||||
// only: it depends on internal/amneziawgnet's own alias mechanism actually
|
||||
// giving the host that address at the OS level (see v6alias.go) — without
|
||||
// that, sendThrough would simply fail to bind and Xray would fall back to
|
||||
// its default outbound, not error out.
|
||||
//
|
||||
// The routing rule matches both inboundTag and user: SocksInboundSettings
|
||||
// (used by injectAmneziawgnetSocks above) already authenticates each
|
||||
// connection as the peer's own email via stock SOCKS5 auth, and a stock
|
||||
// Xray SOCKS5 inbound sets that connection's stats/routing identity from
|
||||
// the authenticated username — so "user" reliably isolates exactly one
|
||||
// peer's traffic, the same building block Finding 3 of the migration plan
|
||||
// already established for per-client stats.
|
||||
//
|
||||
// Modeled on injectNodeEgresses (the established N-per-slice inbound+rule
|
||||
// precedent, not injectAmneziawgnetSocks itself, which only ever emits a
|
||||
// single inbound and never touches outbounds/routing) and
|
||||
// mergeSubscriptionOutbounds's unmarshal-append-remarshal pattern for
|
||||
// cfg.OutboundConfigs. Synthetic rules are prepended ahead of whatever's
|
||||
// already in the routing rules array, the same pattern injectNodeEgresses/
|
||||
// injectMtprotoEgress already use for their own always-must-win infra
|
||||
// rules — this never touches the admin's own saved Routing-page rule
|
||||
// order.
|
||||
func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
// Protocol is checked alongside Tag, not just Tag alone: a tag collision
|
||||
// with some unrelated (non-socks) inbound must not be mistaken for this
|
||||
// instance's own relay having been created.
|
||||
liveInboundTags := make(map[string]struct{}, len(cfg.InboundConfigs))
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Protocol == "socks" {
|
||||
liveInboundTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var existingOutbounds []any
|
||||
if len(cfg.OutboundConfigs) > 0 {
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &existingOutbounds); err != nil {
|
||||
logger.Warning("amneziawg v6 egress: outbounds section is unparsable, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
usedOutboundTags := make(map[string]struct{}, len(existingOutbounds))
|
||||
for _, o := range existingOutbounds {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if t, ok := m["tag"].(string); ok {
|
||||
usedOutboundTags[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
routing := map[string]any{}
|
||||
if len(cfg.RouterConfig) > 0 {
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
logger.Warning("amneziawg v6 egress: routing section is unparsable, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
newRules := make([]any, 0)
|
||||
newOutbounds := make([]any, 0)
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
|
||||
continue
|
||||
}
|
||||
if _, live := liveInboundTags[inbound.Tag]; !live {
|
||||
// The relay inbound itself wasn't created this pass (e.g. a tag
|
||||
// collision inside injectAmneziawgnetSocks) -- no SOCKS5 inbound
|
||||
// exists for hot_diff.go's inboundTag match to ever fire against.
|
||||
continue
|
||||
}
|
||||
inst, ok := amneziawg.InstanceFromInbound(inbound)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, p := range inst.Peers {
|
||||
if p.Email == "" {
|
||||
continue
|
||||
}
|
||||
v6 := amneziawg.FirstIPv6(p.AllowedIPs)
|
||||
if v6 == "" {
|
||||
continue
|
||||
}
|
||||
tag := amneziawgV6EgressTag(inbound.Id, p.Email)
|
||||
if _, taken := usedOutboundTags[tag]; taken {
|
||||
logger.Warning("amneziawg v6 egress: outbound tag [", tag, "] already exists, skipping peer [", p.Email, "]")
|
||||
continue
|
||||
}
|
||||
usedOutboundTags[tag] = struct{}{}
|
||||
newOutbounds = append(newOutbounds, map[string]any{
|
||||
"tag": tag,
|
||||
"protocol": "freedom",
|
||||
"sendThrough": v6,
|
||||
"settings": map[string]any{},
|
||||
})
|
||||
newRules = append(newRules, map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{inbound.Tag},
|
||||
"user": []any{p.Email},
|
||||
"outboundTag": tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(newOutbounds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(existingOutbounds)+len(newOutbounds))
|
||||
merged = append(merged, existingOutbounds...)
|
||||
merged = append(merged, newOutbounds...)
|
||||
combined, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg v6 egress: failed to rebuild outbounds section, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
cfg.OutboundConfigs = json_util.RawMessage(combined)
|
||||
|
||||
routing["rules"] = append(newRules, rules...)
|
||||
newRouting, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg v6 egress: failed to rebuild routing section, skipping injection:", err)
|
||||
return
|
||||
}
|
||||
cfg.RouterConfig = json_util.RawMessage(newRouting)
|
||||
}
|
||||
|
||||
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||
// template so that manually configured outbounds are never overwritten.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
@@ -561,46 +562,46 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
|
||||
}
|
||||
|
||||
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
|
||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: true}
|
||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
|
||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_CreatesBridgeTaggedWithInboundsOwnTag(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
before := string(cfg.RouterConfig)
|
||||
inbound := amneziawgInbound(7, "awg-7", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
||||
|
||||
if len(cfg.InboundConfigs) != 2 {
|
||||
t.Fatalf("expected the bridge to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
||||
t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
|
||||
}
|
||||
ib := cfg.InboundConfigs[1]
|
||||
if ib.Tag != "awg-7" || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPortForInbound(7) {
|
||||
t.Fatalf("bridge must reuse the inbound's own tag (so it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
|
||||
if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) {
|
||||
t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
|
||||
}
|
||||
if string(ib.Listen) != `"127.0.0.1"` {
|
||||
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
|
||||
t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen)
|
||||
}
|
||||
if !strings.Contains(string(ib.StreamSettings), `"tproxy":"tproxy"`) {
|
||||
t.Fatalf("bridge must set sockopt.tproxy, got %s", ib.StreamSettings)
|
||||
if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) {
|
||||
t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings)
|
||||
}
|
||||
if !strings.Contains(string(ib.Settings), `"followRedirect":true`) {
|
||||
t.Fatalf("bridge must set followRedirect, got %s", ib.Settings)
|
||||
if !strings.Contains(string(ib.Settings), `"a@x"`) {
|
||||
t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings)
|
||||
}
|
||||
if !strings.Contains(string(ib.Sniffing), `"enabled":true`) {
|
||||
t.Fatalf("bridge must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
|
||||
t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
|
||||
}
|
||||
// No auto-generated routing rule: it's entirely up to the admin's own
|
||||
// Routing-page rules, same as any other protocol's inbound tag.
|
||||
if string(cfg.RouterConfig) != before {
|
||||
t.Fatalf("injectAmneziawgEgress must never touch the routing section, got %s", cfg.RouterConfig)
|
||||
t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
@@ -608,21 +609,21 @@ func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) {
|
||||
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
||||
})
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2})
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
|
||||
|
||||
if len(cfg.InboundConfigs) != 3 {
|
||||
t.Fatalf("expected one bridge per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
|
||||
t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
|
||||
}
|
||||
byTag := map[string]int{}
|
||||
for _, ib := range cfg.InboundConfigs[1:] {
|
||||
byTag[ib.Tag] = ib.Port
|
||||
}
|
||||
if byTag["awg-1"] != amneziawg.EgressPortForInbound(1) || byTag["awg-2"] != amneziawg.EgressPortForInbound(2) {
|
||||
if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) {
|
||||
t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
client model.Client
|
||||
@@ -632,13 +633,14 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
|
||||
{"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
||||
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
|
||||
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
|
||||
{"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
|
||||
inbound.Enable = c.enable
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
|
||||
}
|
||||
@@ -646,9 +648,13 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
|
||||
// Unlike the retired kernel-module bridge, the embedded relay has no
|
||||
// opt-in gate: there is no alternative datapath once traffic is
|
||||
// decapsulated in gVisor. A stale RouteThroughXray=false left over from
|
||||
// a pre-cutover install must not suppress the relay inbound.
|
||||
cfg := egressTestConfig()
|
||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} // RouteThroughXray left false
|
||||
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false}
|
||||
settings, _ := json.Marshal(amneziawg.InboundSettings{
|
||||
Server: &server,
|
||||
Clients: []model.Client{
|
||||
@@ -656,13 +662,13 @@ func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) {
|
||||
},
|
||||
})
|
||||
inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("an inbound with RouteThroughXray off must never get a bridge, got %+v", cfg.InboundConfigs)
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
|
||||
if len(cfg.InboundConfigs) != 2 {
|
||||
t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
|
||||
nodeID := 5
|
||||
@@ -670,13 +676,13 @@ func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) {
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
|
||||
})
|
||||
nodeHosted.NodeID = &nodeID
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{vless, nodeHosted})
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted})
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a bridge, got %+v", cfg.InboundConfigs)
|
||||
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) {
|
||||
func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
|
||||
@@ -686,20 +692,283 @@ func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) {
|
||||
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
|
||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
|
||||
})
|
||||
injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2})
|
||||
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
|
||||
|
||||
// Started with 2 (api + the colliding vless entry); only awg-2's bridge
|
||||
// should have been added, awg-1's skipped since its tag is taken.
|
||||
// Started with 2 (api + the colliding vless entry); only awg-2's relay
|
||||
// inbound should have been added, awg-1's skipped since its tag is taken.
|
||||
if len(cfg.InboundConfigs) != 3 {
|
||||
t.Fatalf("expected only the non-colliding inbound's bridge to be added, got %+v", cfg.InboundConfigs)
|
||||
t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs)
|
||||
}
|
||||
found := false
|
||||
for _, ib := range cfg.InboundConfigs {
|
||||
if ib.Tag == "awg-2" && ib.Protocol == "dokodemo-door" {
|
||||
if ib.Tag == "awg-2" && ib.Protocol == "socks" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("awg-2's bridge must still be created despite awg-1's tag collision")
|
||||
t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
|
||||
}
|
||||
}
|
||||
|
||||
// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
|
||||
// given external interface -- amneziawgInbound's own ServerSettings never
|
||||
// sets these, so injectAmneziawgV6Egress's tests need their own variant.
|
||||
func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
|
||||
server := amneziawg.ServerSettings{
|
||||
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
|
||||
IPv6Enabled: true, IPv6ExternalInterface: ext6,
|
||||
}
|
||||
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
|
||||
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
|
||||
}
|
||||
|
||||
// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
|
||||
// GetXrayConfig() pipeline and depends on its relay inbound already
|
||||
// existing (see the "live" tag check) -- every test below calls both, in
|
||||
// that order, to match production.
|
||||
func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
injectAmneziawgnetSocks(cfg, inbounds)
|
||||
injectAmneziawgV6Egress(cfg, inbounds)
|
||||
}
|
||||
|
||||
type v6EgressRouting struct {
|
||||
Rules []struct {
|
||||
InboundTag []string `json:"inboundTag"`
|
||||
User []string `json:"user"`
|
||||
OutboundTag string `json:"outboundTag"`
|
||||
Type string `json:"type"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
|
||||
type v6EgressOutbound struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
SendThrough string `json:"sendThrough"`
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
|
||||
var outbounds []v6EgressOutbound
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTag := amneziawgV6EgressTag(7, "a@x")
|
||||
var got *v6EgressOutbound
|
||||
for i := range outbounds {
|
||||
if outbounds[i].Tag == wantTag {
|
||||
got = &outbounds[i]
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
|
||||
}
|
||||
if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
|
||||
t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
|
||||
}
|
||||
// Pre-existing outbounds (direct, warp) must survive untouched.
|
||||
if len(outbounds) != 3 {
|
||||
t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
|
||||
}
|
||||
|
||||
var routing v6EgressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ruleIdx := -1
|
||||
for i := range routing.Rules {
|
||||
if routing.Rules[i].OutboundTag == wantTag {
|
||||
ruleIdx = i
|
||||
}
|
||||
}
|
||||
if ruleIdx == -1 {
|
||||
t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
|
||||
}
|
||||
rule := routing.Rules[ruleIdx]
|
||||
if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
|
||||
len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
|
||||
t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
before := string(cfg.OutboundConfigs)
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
|
||||
})
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
if string(cfg.OutboundConfigs) != before {
|
||||
t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
||||
})
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
|
||||
var outbounds []v6EgressOutbound
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
|
||||
seen := map[string]string{}
|
||||
for _, o := range outbounds {
|
||||
seen[o.Tag] = o.SendThrough
|
||||
}
|
||||
if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
|
||||
t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
|
||||
// Same instance data, two independent injections -- hot_diff.go relies on
|
||||
// the tag being a pure function of (inboundID, email) so it recognizes
|
||||
// "unchanged" rather than remove+recreate on every poll.
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
cfg1 := egressTestConfig()
|
||||
injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
|
||||
cfg2 := egressTestConfig()
|
||||
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
|
||||
|
||||
var out1, out2 []v6EgressOutbound
|
||||
json.Unmarshal(cfg1.OutboundConfigs, &out1)
|
||||
json.Unmarshal(cfg2.OutboundConfigs, &out2)
|
||||
if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
|
||||
t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
before := string(cfg.OutboundConfigs)
|
||||
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
|
||||
nodeID := 5
|
||||
nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
nodeHosted.NodeID = &nodeID
|
||||
disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
|
||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
||||
})
|
||||
disabled.Enable = false
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
|
||||
if string(cfg.OutboundConfigs) != before {
|
||||
t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
// A pre-existing inbound already holds this AmneziaWG inbound's tag, so
|
||||
// injectAmneziawgnetSocks (called first, matching production order)
|
||||
// skips creating its relay SOCKS5 inbound entirely.
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
|
||||
before := string(cfg.OutboundConfigs)
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
if string(cfg.OutboundConfigs) != before {
|
||||
t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
|
||||
})
|
||||
// Pre-seed a colliding outbound tag for a@x specifically.
|
||||
collidingTag := amneziawgV6EgressTag(1, "a@x")
|
||||
existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
|
||||
cfg.OutboundConfigs = json_util.RawMessage(existing)
|
||||
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
|
||||
var outbounds []v6EgressOutbound
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tagB := amneziawgV6EgressTag(1, "b@x")
|
||||
foundB := false
|
||||
countA := 0
|
||||
for _, o := range outbounds {
|
||||
if o.Tag == collidingTag {
|
||||
countA++
|
||||
}
|
||||
if o.Tag == tagB {
|
||||
foundB = true
|
||||
}
|
||||
}
|
||||
if countA != 1 {
|
||||
t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
|
||||
}
|
||||
if !foundB {
|
||||
t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
|
||||
cfg := egressTestConfig()
|
||||
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
if string(cfg.OutboundConfigs) != `{not json` {
|
||||
t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
|
||||
}
|
||||
|
||||
cfg2 := egressTestConfig()
|
||||
cfg2.RouterConfig = json_util.RawMessage(`{not json`)
|
||||
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
|
||||
if string(cfg2.RouterConfig) != `{not json` {
|
||||
t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
|
||||
cfg := egressTestConfig()
|
||||
beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
|
||||
injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
|
||||
if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
|
||||
t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
|
||||
cfg := egressTestConfig() // already has one rule, targeting "api"
|
||||
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
|
||||
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
|
||||
})
|
||||
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
|
||||
|
||||
var routing v6EgressRouting
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(routing.Rules) != 2 {
|
||||
t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
|
||||
}
|
||||
if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
|
||||
t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
|
||||
}
|
||||
if routing.Rules[1].OutboundTag != "api" {
|
||||
t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS الأساسي",
|
||||
"secondaryDns": "DNS الثانوي",
|
||||
"externalInterface": "الواجهة الخارجية",
|
||||
"externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لـ NAT (PostUp/PostDown). اتركها فارغة للاكتشاف التلقائي.",
|
||||
"externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لتعيين (alias) عنوان IPv6 عندما تُترك خانة «واجهة IPv6 الخارجية» فارغة.",
|
||||
"ipv6Enabled": "تفعيل IPv6",
|
||||
"ipv6Subnet": "الشبكة الفرعية IPv6",
|
||||
"ipv6SubnetHint": "مثل fd86:ea04:1115::/64. مطلوب عند تفعيل IPv6.",
|
||||
"ipv6ExternalInterface": "الواجهة الخارجية لـ IPv6",
|
||||
"ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم لإدخالات وكيل NDP. اتركها فارغة لاستخدام الواجهة الخارجية.",
|
||||
"routeThroughXray": "التوجيه عبر Xray",
|
||||
"routeThroughXrayHint": "معطّل افتراضيًا: لا يعتمد النفق على Xray إطلاقًا. عند التفعيل، يُعاد توجيه ترافيك كل عميل إلى جسر Xray الخاص بهذا الاتصال الوارد، ويُختار الاتصال الصادر الفعلي من صفحة التوجيه تمامًا مثل أي بروتوكول آخر.",
|
||||
"ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم التي يُعيَّن (alias) عليها عنوان IPv6 الخاص بكل عميل. اتركها فارغة لاستخدام الواجهة الخارجية.",
|
||||
"obfuscation": "معاملات التمويه",
|
||||
"regenerateObfuscation": "إعادة التوليد",
|
||||
"jc": "Jc (عدد الحزم العشوائية)",
|
||||
|
||||
@@ -1765,14 +1765,12 @@
|
||||
"primaryDns": "Primary DNS",
|
||||
"secondaryDns": "Secondary DNS",
|
||||
"externalInterface": "External Interface",
|
||||
"externalInterfaceHint": "Host NIC for NAT (PostUp/PostDown). Leave empty to auto-detect.",
|
||||
"externalInterfaceHint": "Host NIC for the IPv6 address alias when IPv6 External Interface is left empty.",
|
||||
"ipv6Enabled": "Enable IPv6",
|
||||
"ipv6Subnet": "IPv6 Subnet",
|
||||
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
|
||||
"ipv6ExternalInterface": "IPv6 External Interface",
|
||||
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
|
||||
"routeThroughXray": "Route through Xray",
|
||||
"routeThroughXrayHint": "Off by default: the tunnel has no dependency on Xray at all. When on, every client's traffic is redirected into this inbound's own Xray bridge, and the actual outbound is chosen from the Routing page like any other protocol.",
|
||||
"ipv6ExternalInterfaceHint": "Host NIC each peer's IPv6 address is aliased onto. Leave empty to reuse External Interface.",
|
||||
"obfuscation": "Obfuscation parameters",
|
||||
"regenerateObfuscation": "Regenerate",
|
||||
"jc": "Jc (junk packet count)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS primario",
|
||||
"secondaryDns": "DNS secundario",
|
||||
"externalInterface": "Interfaz externa",
|
||||
"externalInterfaceHint": "Interfaz de red del host para NAT (PostUp/PostDown). Déjalo vacío para autodetectar.",
|
||||
"externalInterfaceHint": "Interfaz de red del host para el alias de la dirección IPv6 cuando se deja vacía la Interfaz Externa IPv6.",
|
||||
"ipv6Enabled": "Habilitar IPv6",
|
||||
"ipv6Subnet": "Subred IPv6",
|
||||
"ipv6SubnetHint": "p. ej. fd86:ea04:1115::/64. Obligatorio cuando IPv6 está habilitado.",
|
||||
"ipv6ExternalInterface": "Interfaz externa IPv6",
|
||||
"ipv6ExternalInterfaceHint": "Interfaz de red del host para las entradas de proxy NDP. Déjalo vacío para reutilizar la interfaz externa.",
|
||||
"routeThroughXray": "Enrutar a través de Xray",
|
||||
"routeThroughXrayHint": "Desactivado de forma predeterminada: el túnel no depende de Xray en absoluto. Al activarlo, el tráfico de cada cliente se redirige al puente Xray propio de esta entrada, y la salida real se elige desde la página de Enrutamiento igual que con cualquier otro protocolo.",
|
||||
"ipv6ExternalInterfaceHint": "Interfaz de red del host en la que se alía la dirección IPv6 de cada cliente. Déjalo vacío para reutilizar la Interfaz Externa.",
|
||||
"obfuscation": "Parámetros de ofuscación",
|
||||
"regenerateObfuscation": "Regenerar",
|
||||
"jc": "Jc (cantidad de paquetes basura)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS اصلی",
|
||||
"secondaryDns": "DNS ثانویه",
|
||||
"externalInterface": "رابط خارجی",
|
||||
"externalInterfaceHint": "رابط شبکه میزبان برای NAT (PostUp/PostDown). برای تشخیص خودکار خالی بگذارید.",
|
||||
"externalInterfaceHint": "رابط شبکه میزبان برای alias کردن آدرس IPv6 وقتی «رابط خارجی IPv6» خالی گذاشته شود.",
|
||||
"ipv6Enabled": "فعالسازی IPv6",
|
||||
"ipv6Subnet": "زیرشبکه IPv6",
|
||||
"ipv6SubnetHint": "مثلاً fd86:ea04:1115::/64. هنگام فعال بودن IPv6 الزامی است.",
|
||||
"ipv6ExternalInterface": "رابط خارجی IPv6",
|
||||
"ipv6ExternalInterfaceHint": "رابط شبکه میزبان برای ورودیهای پراکسی NDP. برای استفاده از رابط خارجی خالی بگذارید.",
|
||||
"routeThroughXray": "مسیریابی از طریق Xray",
|
||||
"routeThroughXrayHint": "بهطور پیشفرض خاموش است: تونل هیچ وابستگیای به Xray ندارد. با روشنکردن آن، ترافیک هر کلاینت به پل Xray مخصوص همین اینباند هدایت میشود و مسیر خروجی واقعی از صفحه مسیریابی، دقیقاً مثل هر پروتکل دیگر، انتخاب میشود.",
|
||||
"ipv6ExternalInterfaceHint": "رابط شبکه میزبانی که آدرس IPv6 هر کلاینت روی آن alias میشود. برای استفاده از رابط خارجی خالی بگذارید.",
|
||||
"obfuscation": "پارامترهای مبهمسازی",
|
||||
"regenerateObfuscation": "بازتولید",
|
||||
"jc": "Jc (تعداد بستههای زباله)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS Utama",
|
||||
"secondaryDns": "DNS Cadangan",
|
||||
"externalInterface": "Antarmuka Eksternal",
|
||||
"externalInterfaceHint": "NIC host untuk NAT (PostUp/PostDown). Biarkan kosong untuk deteksi otomatis.",
|
||||
"externalInterfaceHint": "NIC host untuk alias alamat IPv6 saat IPv6 External Interface dikosongkan.",
|
||||
"ipv6Enabled": "Aktifkan IPv6",
|
||||
"ipv6Subnet": "Subnet IPv6",
|
||||
"ipv6SubnetHint": "mis. fd86:ea04:1115::/64. Wajib diisi saat IPv6 diaktifkan.",
|
||||
"ipv6ExternalInterface": "NIC Eksternal IPv6",
|
||||
"ipv6ExternalInterfaceHint": "NIC host untuk entri proxy NDP. Biarkan kosong untuk menggunakan NIC Eksternal.",
|
||||
"routeThroughXray": "Rutekan melalui Xray",
|
||||
"routeThroughXrayHint": "Nonaktif secara default: tunnel tidak bergantung pada Xray sama sekali. Jika diaktifkan, trafik setiap klien dialihkan ke bridge Xray milik inbound ini sendiri, dan outbound sebenarnya dipilih dari halaman Routing seperti protokol lainnya.",
|
||||
"ipv6ExternalInterfaceHint": "NIC host tempat alamat IPv6 setiap klien dialiaskan. Biarkan kosong untuk menggunakan NIC Eksternal.",
|
||||
"obfuscation": "Parameter obfuskasi",
|
||||
"regenerateObfuscation": "Buat ulang",
|
||||
"jc": "Jc (jumlah paket sampah)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "プライマリDNS",
|
||||
"secondaryDns": "セカンダリDNS",
|
||||
"externalInterface": "外部インターフェース",
|
||||
"externalInterfaceHint": "NAT(PostUp/PostDown)に使用するホストのNIC。空欄で自動検出。",
|
||||
"externalInterfaceHint": "IPv6外部NICが空欄の場合に、IPv6アドレスのエイリアスに使用するホストのNIC。",
|
||||
"ipv6Enabled": "IPv6を有効化",
|
||||
"ipv6Subnet": "IPv6サブネット",
|
||||
"ipv6SubnetHint": "例: fd86:ea04:1115::/64。IPv6有効時は必須。",
|
||||
"ipv6ExternalInterface": "IPv6外部NIC",
|
||||
"ipv6ExternalInterfaceHint": "NDPプロキシエントリに使用するホストのNIC。空欄で外部NICを使用。",
|
||||
"routeThroughXray": "Xray経由でルーティング",
|
||||
"routeThroughXrayHint": "デフォルトではオフです。オフの場合トンネルはXrayに一切依存しません。オンにすると、各クライアントのトラフィックはこのインバウンド専用のXrayブリッジへリダイレクトされ、実際のアウトバウンドは他のプロトコルと同様にルーティングページから選択します。",
|
||||
"ipv6ExternalInterfaceHint": "各クライアントのIPv6アドレスをエイリアスするホストのNIC。空欄で外部NICを使用。",
|
||||
"obfuscation": "難読化パラメータ",
|
||||
"regenerateObfuscation": "再生成",
|
||||
"jc": "Jc(ジャンクパケット数)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS Primário",
|
||||
"secondaryDns": "DNS Secundário",
|
||||
"externalInterface": "Interface Externa",
|
||||
"externalInterfaceHint": "Interface de rede do host para NAT (PostUp/PostDown). Deixe vazio para detecção automática.",
|
||||
"externalInterfaceHint": "Interface de rede do host para o alias de endereço IPv6 quando a Interface Externa IPv6 estiver vazia.",
|
||||
"ipv6Enabled": "Ativar IPv6",
|
||||
"ipv6Subnet": "Sub-rede IPv6",
|
||||
"ipv6SubnetHint": "ex. fd86:ea04:1115::/64. Obrigatório quando o IPv6 está ativado.",
|
||||
"ipv6ExternalInterface": "Interface externa IPv6",
|
||||
"ipv6ExternalInterfaceHint": "Interface de rede do host para as entradas de proxy NDP. Deixe vazio para reutilizar a interface externa.",
|
||||
"routeThroughXray": "Rotear pelo Xray",
|
||||
"routeThroughXrayHint": "Desativado por padrão: o túnel não depende do Xray de forma alguma. Quando ativado, o tráfego de cada cliente é redirecionado para a própria ponte Xray desta entrada, e a saída real é escolhida na página de Roteamento, como em qualquer outro protocolo.",
|
||||
"ipv6ExternalInterfaceHint": "Interface de rede do host na qual o endereço IPv6 de cada cliente é associado (alias). Deixe vazio para reutilizar a Interface Externa.",
|
||||
"obfuscation": "Parâmetros de ofuscação",
|
||||
"regenerateObfuscation": "Regenerar",
|
||||
"jc": "Jc (quantidade de pacotes de lixo)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "Основной DNS",
|
||||
"secondaryDns": "Резервный DNS",
|
||||
"externalInterface": "Внешний интерфейс",
|
||||
"externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.",
|
||||
"externalInterfaceHint": "Сетевой интерфейс хоста для алиаса IPv6-адреса, если поле «Внешний интерфейс IPv6» оставлено пустым.",
|
||||
"ipv6Enabled": "Включить IPv6",
|
||||
"ipv6Subnet": "Подсеть IPv6",
|
||||
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
|
||||
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
|
||||
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
||||
"routeThroughXray": "Маршрутизировать через Xray",
|
||||
"routeThroughXrayHint": "По умолчанию выключено: туннель никак не зависит от Xray. Если включить, трафик каждого клиента перенаправляется в собственный мост Xray этого входящего соединения, а фактический исходящий выбирается на странице «Маршрутизация», как и для любого другого протокола.",
|
||||
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста, на который алиасится IPv6-адрес каждого клиента. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
|
||||
"obfuscation": "Параметры обфускации",
|
||||
"regenerateObfuscation": "Сгенерировать заново",
|
||||
"jc": "Jc (кол-во мусорных пакетов)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "Birincil DNS",
|
||||
"secondaryDns": "İkincil DNS",
|
||||
"externalInterface": "Harici Arayüz",
|
||||
"externalInterfaceHint": "NAT (PostUp/PostDown) için sunucu ağ arayüzü. Otomatik algılama için boş bırakın.",
|
||||
"externalInterfaceHint": "IPv6 Harici Arayüzü boş bırakıldığında IPv6 adres takma adı (alias) için kullanılan sunucu ağ arayüzü.",
|
||||
"ipv6Enabled": "IPv6'yı Etkinleştir",
|
||||
"ipv6Subnet": "IPv6 Alt Ağı",
|
||||
"ipv6SubnetHint": "örn. fd86:ea04:1115::/64. IPv6 etkinken zorunludur.",
|
||||
"ipv6ExternalInterface": "IPv6 Harici Arayüzü",
|
||||
"ipv6ExternalInterfaceHint": "NDP proxy girişleri için sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.",
|
||||
"routeThroughXray": "Xray üzerinden yönlendir",
|
||||
"routeThroughXrayHint": "Varsayılan olarak kapalıdır: tünelin Xray'e hiçbir bağımlılığı yoktur. Açıldığında, her istemcinin trafiği bu gelen bağlantıya ait Xray köprüsüne yönlendirilir ve gerçek giden bağlantı, diğer tüm protokollerde olduğu gibi Yönlendirme sayfasından seçilir.",
|
||||
"ipv6ExternalInterfaceHint": "Her istemcinin IPv6 adresinin takma ad (alias) olarak atandığı sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.",
|
||||
"obfuscation": "Gizleme parametreleri",
|
||||
"regenerateObfuscation": "Yeniden oluştur",
|
||||
"jc": "Jc (gereksiz paket sayısı)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "Основний DNS",
|
||||
"secondaryDns": "Резервний DNS",
|
||||
"externalInterface": "Зовнішній інтерфейс",
|
||||
"externalInterfaceHint": "Мережевий інтерфейс хоста для NAT (PostUp/PostDown). Залиште порожнім для автовизначення.",
|
||||
"externalInterfaceHint": "Мережевий інтерфейс хоста для аліасу IPv6-адреси, якщо поле «Зовнішній інтерфейс IPv6» залишено порожнім.",
|
||||
"ipv6Enabled": "Увімкнути IPv6",
|
||||
"ipv6Subnet": "Підмережа IPv6",
|
||||
"ipv6SubnetHint": "напр. fd86:ea04:1115::/64. Обов'язково, якщо IPv6 увімкнено.",
|
||||
"ipv6ExternalInterface": "Зовнішній інтерфейс IPv6",
|
||||
"ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста для записів NDP-проксі. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.",
|
||||
"routeThroughXray": "Маршрутизувати через Xray",
|
||||
"routeThroughXrayHint": "За замовчуванням вимкнено: тунель жодним чином не залежить від Xray. Якщо увімкнено, трафік кожного клієнта перенаправляється у власний міст Xray цього вхідного з'єднання, а фактичне вихідне з'єднання обирається на сторінці Маршрутизація, як і для будь-якого іншого протоколу.",
|
||||
"ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста, на який прив'язується (аліас) IPv6-адреса кожного клієнта. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.",
|
||||
"obfuscation": "Параметри обфускації",
|
||||
"regenerateObfuscation": "Згенерувати заново",
|
||||
"jc": "Jc (кількість сміттєвих пакетів)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "DNS chính",
|
||||
"secondaryDns": "DNS phụ",
|
||||
"externalInterface": "Giao diện ngoài",
|
||||
"externalInterfaceHint": "Card mạng của host dùng cho NAT (PostUp/PostDown). Để trống để tự động phát hiện.",
|
||||
"externalInterfaceHint": "Card mạng của host dùng để gán (alias) địa chỉ IPv6 khi Card mạng ngoài IPv6 để trống.",
|
||||
"ipv6Enabled": "Bật IPv6",
|
||||
"ipv6Subnet": "Subnet IPv6",
|
||||
"ipv6SubnetHint": "vd. fd86:ea04:1115::/64. Bắt buộc khi bật IPv6.",
|
||||
"ipv6ExternalInterface": "Card mạng ngoài IPv6",
|
||||
"ipv6ExternalInterfaceHint": "Card mạng của host dùng cho các mục NDP proxy. Để trống để dùng lại Card mạng ngoài.",
|
||||
"routeThroughXray": "Định tuyến qua Xray",
|
||||
"routeThroughXrayHint": "Mặc định tắt: tunnel hoàn toàn không phụ thuộc vào Xray. Khi bật, lưu lượng của mỗi client sẽ được chuyển hướng vào cầu nối Xray riêng của inbound này, và outbound thực tế được chọn từ trang Định tuyến giống như mọi giao thức khác.",
|
||||
"ipv6ExternalInterfaceHint": "Card mạng của host mà địa chỉ IPv6 của mỗi client được gán (alias) vào. Để trống để dùng lại Card mạng ngoài.",
|
||||
"obfuscation": "Tham số làm rối (obfuscation)",
|
||||
"regenerateObfuscation": "Tạo lại",
|
||||
"jc": "Jc (số lượng gói rác)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "主 DNS",
|
||||
"secondaryDns": "备用 DNS",
|
||||
"externalInterface": "外部网卡",
|
||||
"externalInterfaceHint": "用于 NAT(PostUp/PostDown)的主机网卡。留空则自动检测。",
|
||||
"externalInterfaceHint": "当「IPv6 外部网卡」留空时,用于 IPv6 地址别名的主机网卡。",
|
||||
"ipv6Enabled": "启用 IPv6",
|
||||
"ipv6Subnet": "IPv6 子网",
|
||||
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。",
|
||||
"ipv6ExternalInterface": "IPv6 外部网卡",
|
||||
"ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。",
|
||||
"routeThroughXray": "通过 Xray 路由",
|
||||
"routeThroughXrayHint": "默认关闭:隧道完全不依赖 Xray。开启后,每个客户端的流量都会被重定向到该入站自己的 Xray 网桥,实际的出站则和其他协议一样,在路由页面中选择。",
|
||||
"ipv6ExternalInterfaceHint": "用于别名绑定每个客户端 IPv6 地址的主机网卡。留空则使用外部网卡。",
|
||||
"obfuscation": "混淆参数",
|
||||
"regenerateObfuscation": "重新生成",
|
||||
"jc": "Jc(垃圾包数量)",
|
||||
|
||||
@@ -1648,14 +1648,12 @@
|
||||
"primaryDns": "主要 DNS",
|
||||
"secondaryDns": "次要 DNS",
|
||||
"externalInterface": "外部網路介面",
|
||||
"externalInterfaceHint": "用於 NAT(PostUp/PostDown)的主機網路介面。留空則自動偵測。",
|
||||
"externalInterfaceHint": "當「IPv6 外部網路介面」留空時,用於 IPv6 位址別名的主機網路介面。",
|
||||
"ipv6Enabled": "啟用 IPv6",
|
||||
"ipv6Subnet": "IPv6 子網路",
|
||||
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。",
|
||||
"ipv6ExternalInterface": "IPv6 外部網路介面",
|
||||
"ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。",
|
||||
"routeThroughXray": "透過 Xray 路由",
|
||||
"routeThroughXrayHint": "預設關閉:通道完全不依賴 Xray。啟用後,每個客戶端的流量都會被重新導向到該入站自己的 Xray 橋接,實際的出站則和其他協定一樣,在路由頁面中選擇。",
|
||||
"ipv6ExternalInterfaceHint": "用於別名綁定每個客戶端 IPv6 位址的主機網路介面。留空則使用外部網路介面。",
|
||||
"obfuscation": "混淆參數",
|
||||
"regenerateObfuscation": "重新產生",
|
||||
"jc": "Jc(垃圾封包數量)",
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -689,7 +689,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
if stopXray {
|
||||
_ = s.xrayService.StopXray()
|
||||
mtproto.GetManager().StopAll()
|
||||
amneziawg.GetManager().StopAll()
|
||||
amneziawgnet.GetManager().StopAll()
|
||||
}
|
||||
if s.cron != nil {
|
||||
s.cron.Stop()
|
||||
|
||||
Reference in New Issue
Block a user