diff --git a/.github/workflows/build-go.yml b/.github/workflows/build-go.yml index 4ec853b..aa35196 100644 --- a/.github/workflows/build-go.yml +++ b/.github/workflows/build-go.yml @@ -214,29 +214,6 @@ jobs: go_os: darwin cgo_enabled: "0" smoke_test: false - - os: ubuntu-22.04 - platform: Termux - arch: arm64 - ext: "" - package: tar.gz - go_arch: arm64 - go_os: android - cgo_enabled: "0" - smoke_test: false - - os: ubuntu-22.04 - platform: Termux - arch: armv7 - ext: "" - package: tar.gz - go_arch: arm - go_arm: "7" - go_os: android - cgo_enabled: "1" - android_ndk: true - android_api: "21" - android_cc: armv7a-linux-androideabi21-clang - android_cxx: armv7a-linux-androideabi21-clang++ - smoke_test: false steps: - name: Checkout repository @@ -248,30 +225,6 @@ jobs: go-version-file: go.mod cache: true - - name: Set up Java 17 for Android builds - if: ${{ matrix.android_ndk }} - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - - name: Set up Android SDK - if: ${{ matrix.android_ndk }} - uses: android-actions/setup-android@v3 - - - name: Install Android NDK - if: ${{ matrix.android_ndk }} - shell: bash - run: | - set -euo pipefail - sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" "ndk;27.2.12479018" - NDK_ROOT="${ANDROID_SDK_ROOT}/ndk/27.2.12479018" - test -x "${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cc }}" - test -x "${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cxx }}" - echo "ANDROID_NDK_ROOT=${NDK_ROOT}" >> "$GITHUB_ENV" - echo "CC=${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cc }}" >> "$GITHUB_ENV" - echo "CXX=${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cxx }}" >> "$GITHUB_ENV" - - name: Install goversioninfo if: ${{ matrix.go_os == 'windows' }} run: | diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 28cb109..40bd5c4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -213,29 +213,6 @@ jobs: go_os: darwin cgo_enabled: "0" smoke_test: false - - os: ubuntu-22.04 - platform: Termux - arch: arm64 - ext: "" - package: tar.gz - go_arch: arm64 - go_os: android - cgo_enabled: "0" - smoke_test: false - - os: ubuntu-22.04 - platform: Termux - arch: armv7 - ext: "" - package: tar.gz - go_arch: arm - go_arm: "7" - go_os: android - cgo_enabled: "1" - android_ndk: true - android_api: "21" - android_cc: armv7a-linux-androideabi21-clang - android_cxx: armv7a-linux-androideabi21-clang++ - smoke_test: false steps: - name: Checkout repository @@ -247,30 +224,6 @@ jobs: go-version-file: go.mod cache: true - - name: Set up Java 17 for Android builds - if: ${{ matrix.android_ndk }} - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - - name: Set up Android SDK - if: ${{ matrix.android_ndk }} - uses: android-actions/setup-android@v3 - - - name: Install Android NDK - if: ${{ matrix.android_ndk }} - shell: bash - run: | - set -euo pipefail - sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" "ndk;27.2.12479018" - NDK_ROOT="${ANDROID_SDK_ROOT}/ndk/27.2.12479018" - test -x "${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cc }}" - test -x "${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cxx }}" - echo "ANDROID_NDK_ROOT=${NDK_ROOT}" >> "$GITHUB_ENV" - echo "CC=${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cc }}" >> "$GITHUB_ENV" - echo "CXX=${NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.android_cxx }}" >> "$GITHUB_ENV" - - name: Install goversioninfo if: ${{ matrix.go_os == 'windows' }} run: | diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 8304bb3..cc879e8 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -83,8 +83,9 @@ jobs: android-engine: runs-on: ubuntu-latest - # Android-source contract: this repo is checked out at a pinned commit and - # built as the WhiteDNS Android app's native engine. Cross-compile all four + # Android-source contract: this repo is consumed as a pinned source snapshot by the + # WhiteDNS Android app, which builds the native engine itself from this + # source — no Android artifact is released here. Cross-compile all four # ABIs through scripts/build-android-client.sh on every push so main can never # silently stop being a valid Android engine source. Uses the NDK preinstalled # on the runner rather than downloading a pinned one — this validates the build diff --git a/.gitignore b/.gitignore index 58cb9c3..cc20d50 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ logs/ *.tmp *.exe build/ +dist/ .gocache/ .gocache-audit/ .bench/ @@ -35,3 +36,6 @@ cmd/*/resource_windows_*.syso .gochache/ .android-build-audit/ .tmp-android-engine-upstream/ + +# Local engineering audit (human-readable, intentionally not committed) +reports/cottendns-network-engineering-audit.html diff --git a/README_ES.MD b/README_ES.MD new file mode 100644 index 0000000..f680b50 --- /dev/null +++ b/README_ES.MD @@ -0,0 +1,358 @@ +

+ Logotipo de CottenDNS +

+ +

CottenDNS

+ +

+ Un túnel DNS centrado en velocidad para redes censuradas, inestables y con pérdidas. +

+ +

+ Licencia MIT + Compilación + Última versión +

+ +

+ English · + فارسی · + Русский · + 简体中文 · + Español +

+ +

+ Descargar · + Notas de ingeniería · + Perfiles · + Telegram +

+ +> [!IMPORTANT] +> CottenDNS está diseñado para redes donde los VPN y proxies normales están bloqueados, pero aún existe una ruta DNS utilizable. Un túnel DNS es inherentemente más lento que un VPN normal. El objetivo es obtener la máxima velocidad útil y continuidad bajo censura. + +## Empieza aquí + +| Objetivo | Sección | +| --- | --- | +| Desplegar un túnel nuevo | [Guía completa](#guía-completa-de-instalación) | +| Entender la selección de transporte | [Comportamiento automático](#comportamiento-automático) | +| Elegir un perfil | [Perfiles](#perfiles) | +| Revisar el servidor | [Monitorización](#monitorización) | +| Integrar el motor Android | [Android y submodule](#android-y-submodule) | + +## Origen y créditos + +CottenDNS se apoya en el trabajo de dos proyectos: + +1. **[MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN)** de **Amin Mahmoudi** creó la base de bajo overhead, ARQ personalizado, balanceo de resolvers, multiplexación y diseño para redes hostiles. +2. **[StormDNS](https://github.com/nullroute1970/StormDNS)**, mantenido por **NullRoute1970**, es el upstream directo del que deriva CottenDNS. +3. **CottenDNS**, mantenido por **tajirax**, amplía esa base y conserva compatibilidad con clientes MasterDNS/StormDNS. + +CottenDNS es un derivado independiente; no afirma haber creado la arquitectura original. Los proyectos conservan el linaje de licencia MIT. Consulta [LICENSE](LICENSE). + +## Cómo funciona + +```text +Aplicación + │ + ▼ +SOCKS5 / TCP local + │ + ▼ +Cliente CottenDNS + ├── UDP/53 ─────┐ + ├── TCP/53 ─────┤ + ├── DoT ────────┤──► Resolver DNS ──► Servidor CottenDNS ──► Internet + └── DoH ────────┘ +``` + +El cliente divide los flujos en tramas pequeñas aptas para DNS, las cifra y comprime opcionalmente, selecciona una ruta de resolver/transporte y repara pérdidas mediante ARQ y FEC Reed–Solomon. El servidor reconstruye los flujos y abre la conexión directamente, mediante un SOCKS5 upstream o hacia un destino TCP fijo. + +El SOCKS5 integrado admite TCP y asociaciones UDP genéricas. El servicio DNS local y su caché son opcionales. + +## Ventajas principales + +| Problema de red | Respuesta de CottenDNS | +| --- | --- | +| UDP es rápido pero puede ser envenenado o limitado | Conserva UDP viable y cambia solamente el resolver afectado | +| Cada resolver se comporta distinto | Evalúa cada `(resolver, transporte)` por separado | +| Subida y bajada tienen distinta calidad | Evidencia direccional separada de entrega, fallos, RTT y confianza | +| MTU DNS pequeño | Descubrimiento por ruta y selección según el tamaño de cada paquete | +| Pérdida severa | ARQ, ACK/NACK, replay inmediato, duplicación adaptativa y FEC | +| Una respuesta falsa llega demasiado rápido | La usa como alerta y compite con una alternativa autenticada | +| Muere una ruta con tramas en vuelo | Reproduce tramas no confirmadas sin recrear la sesión | +| La redundancia congestiona el enlace | FEC, duplicación, replay y exploración comparten un presupuesto | +| Varias rutas son equivalentes | Distribuye tráfico bulk de una sola copia entre rutas maduras | +| DPI reconoce DNS repetitivo | Rotación de registros, forma QNAME, ID/cookies aleatorios y DoT/DoH opcional | + +## Comportamiento automático + +Valores recomendados: + +```toml +CONFIG_PRESET = "speed" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +El cliente automáticamente: + +- prueba UDP primero y lo conserva mientras funcione bien; +- compara UDP y TCP/53 para cada resolver; +- cambia únicamente la ruta que falla o es materialmente más lenta; +- considera tamaño, MTU, RTT, pérdida, dirección y confianza; +- mantiene alternativas activas con un presupuesto de fondo mínimo; +- evita acumular FEC, exploración y duplicación innecesaria; +- compite con una alternativa cuando detecta poisoning; +- reproduce tramas en vuelo antes de esperar todo el RTO de ARQ. + +`auto` no activa DoT/DoH sin permiso. Son opciones explícitas: + +```toml +RESOLVER_TRANSPORT = "dot" # o "doh" +``` + +Overrides por resolver: + +```toml +RESOLVER_TRANSPORT_PATHS = { "1.1.1.1" = "doh", "8.8.8.8" = "auto", "9.9.9.9" = "tcp" } +``` + +## Servidor dinámico + +UDP, TCP/53, DoT y DoH alimentan el mismo motor nativo de sesiones. Con: + +```toml +ENCRYPTION_AUTO_DETECT = true +``` + +el servidor acepta automáticamente los métodos con clave 1–5 elegidos por los clientes. AES-128/192/256-GCM siempre se prueba antes que los decodificadores legacy no autenticados. XOR y ChaCha20 también deben coincidir con una sesión válida o con la semántica estricta de MTU. El método plaintext `0` solo se permite cuando el servidor lo habilita expresamente. + +Distintos clientes pueden elegir distintos cifrados, transportes, MTU, compresión y fiabilidad sin reconfigurar el servidor. El servidor sigue aplicando sus límites de seguridad. + +## Guía completa de instalación + +Flujo habitual: + +```text +[Dispositivo cliente] → [resolvers DNS] → [subdominio delegado en el VPS] → [Internet] +``` + +### Antes de empezar + +Necesitas un dominio cuyas entradas DNS puedas editar, un VPS Linux con IPv4 pública y acceso `sudo`, UDP/53 y TCP/53 entrantes permitidos, y un cliente Windows, Linux o macOS. Abre el puerto 53 tanto en el firewall del sistema como en el firewall o grupo de seguridad del proveedor. + +> [!CAUTION] +> `systemd-resolved`, BIND, dnsmasq o un panel pueden estar usando el puerto 53. Compruébalo con `sudo ss -lntup | grep ':53 '`. Mantén abierto SSH o tu puerto de administración para no perder acceso al cambiar el firewall. + +### Paso 1 — Delega un subdominio corto + +Crea estas entradas en el proveedor DNS de `example.com`: + +```dns-zone +ns.example.com. 300 IN A 203.0.113.10 +v.example.com. 300 IN NS ns.example.com. +``` + +Sustituye la IP por la IPv4 pública del VPS. No actives el proxy CDN para estas entradas. Un nombre corto deja más espacio útil dentro de cada paquete DNS. + +Comprueba la propagación: + +```bash +dig +short A ns.example.com +dig +short NS v.example.com +``` + +Debes recibir la IP del servidor y `ns.example.com.`. Si falta el resultado NS, corrige la delegación antes de configurar el cliente. + +### Paso 2 — Instala el servidor + +```bash +sudo install -d -m 0755 /opt/cottendns +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash +``` + +Cuando el instalador pregunte, introduce `v.example.com`. Descargará el release, preparará configuración y clave, instalará el servicio systemd, abrirá UDP/TCP 53 en firewalls compatibles y verificará `/healthz`. + +Archivos importantes: + +```text +/opt/cottendns/server_config.toml +/opt/cottendns/encrypt_key.txt +``` + +Copia la clave al cliente mediante un canal seguro: + +```bash +sudo cat /opt/cottendns/encrypt_key.txt +``` + +Nunca publiques la clave en capturas, Issues, configuraciones públicas o listas de resolvers. + +### Paso 3 — Verifica el servidor + +```bash +systemctl is-active cottendns +curl -fsS http://127.0.0.1:9090/healthz +sudo ss -lnup | grep ':53 ' +sudo ss -lntp | grep ':53 ' +``` + +Si falla: + +```bash +journalctl -u cottendns -n 100 --no-pager +``` + +El puerto de monitorización `9090` es local por defecto; no lo expongas directamente a Internet. + +### Paso 4 — Configura el cliente + +Descarga desde [Releases](https://github.com/TaJirax/cottenDNS/releases/latest) el archivo correspondiente a tu sistema y arquitectura. Conserva el ejecutable, `client_config.toml` y `client_resolvers.txt` en la misma carpeta. + +Empieza con esta configuración mínima y segura: + +```toml +DOMAINS = ["v.example.com"] +ENCRYPTION_KEY = "clave-del-servidor" +DATA_ENCRYPTION_METHOD = 3 + +PROTOCOL_TYPE = "SOCKS5" +LISTEN_IP = "127.0.0.1" +LISTEN_PORT = 18000 + +CONFIG_PRESET = "speed" +STARTUP_MODE = "resolvers" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +`client_resolvers.txt`: + +```text +1.1.1.1 +8.8.8.8 +9.9.9.9 +``` + +Empieza con una lista pequeña y diversa. Una lista enorme de resolvers deficientes solo alarga el escaneo. + +### Paso 5 — Primer escaneo y arranque + +Linux: + +```bash +chmod +x ./CottenDns_Client_* +./CottenDns_Client_Linux_AMD64_vVERSION --config ./client_config.toml +``` + +Windows PowerShell: + +```powershell +.\CottenDns_Client_Windows_AMD64_vVERSION.exe --config .\client_config.toml +``` + +El cliente medirá conectividad real del túnel, MTU, latencia, pérdida y rutas UDP/TCP por resolver. Para escanear sin iniciar el proxy, añade `--scan-only`. + +Mantén `STARTUP_MODE = "resolvers"` en cada inicio. La conectividad, el +envenenamiento, el transporte y el MTU pueden cambiar entre reinicios. +`FAST_CONNECT = true` permite conectar cuando existe un pequeño pool recién +validado y continúa comprobando los demás resolvers en segundo plano sin confiar +en una caché de procesos anteriores. + +### Paso 6 — Prueba SOCKS5 + +Configura `SOCKS5 127.0.0.1:18000` en la aplicación o ejecuta: + +```bash +curl --proxy socks5h://127.0.0.1:18000 https://example.com/ +``` + +`socks5h` hace que la resolución del nombre también viaje por el proxy. + +### Actualización segura + +```bash +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash -s -- --upgrade +``` + +La actualización conserva configuración y clave, y revierte si el servicio nuevo no supera el health check. Para el cliente de escritorio, extrae el release nuevo en otra carpeta y conserva el anterior hasta completar el escaneo y la prueba SOCKS. + +## Perfiles + +| Perfil | Cuándo usarlo | +| --- | --- | +| `speed` | Opción normal para máxima velocidad útil | +| `survival` | Pérdida alta, resolvers inestables o censura agresiva | +| `tcp-survival` | UDP/53 no funciona y TCP/53 sobrevive | +| `iran`, `china`, `russia`, `venezuela`, `cuba` | Puntos de partida para restricciones regionales | +| `low-bandwidth` | Subida muy limitada o presupuesto DNS pequeño | + +Son puntos de partida, no detección automática del país. Consulta [CONFIG_PRESETS.md](CONFIG_PRESETS.md). + +## Monitorización + +```bash +curl -s http://127.0.0.1:9090/healthz +curl -s 'http://127.0.0.1:9090/healthz?details=1' | jq +curl -s http://127.0.0.1:9090/metrics +``` + +Uso del proceso: + +```bash +pid=$(systemctl show -p MainPID --value cottendns) +ps -p "$pid" -o pid,%cpu,%mem,rss,vsz,etime,cmd +top -p "$pid" +``` + +## Android y submodule + +Una aplicación Android puede fijar este repositorio como submodule Git o dependencia de CI. Commit verificado: + +```text +8eea49d1267b1a41441c25c75e69e6f12ad0a11f +``` + +El motor compila sin CGO para Android arm64, armv7, amd64 y 386. La capa Android debe conservar los valores TOML recomendados. + +## Compatibilidad y seguridad + +- Cliente nuevo + servidor nuevo: todas las funciones. +- Cliente antiguo + servidor nuevo: compatible, incluidos MasterDNS/StormDNS. +- Cliente nuevo + servidor antiguo: mejoras del cliente disponibles, sin las funciones nuevas del servidor. +- Usa AES-GCM `3`, `4` o `5` para cifrado autenticado. +- DoT/DoH protege el tramo cliente→resolver, no todo el recorrido. +- El volumen y temporización todavía pueden revelar un túnel. +- No publiques `encrypt_key.txt`. +- Utiliza el proyecto solo donde estés autorizado. + +## Documentación y desarrollo + +| Documento | Contenido | +| --- | --- | +| [CONFIG_PRESETS.md](CONFIG_PRESETS.md) | Perfiles de velocidad, supervivencia y región | +| [ENGINEERING_CHANGES.md](docs/ENGINEERING_CHANGES.md) | Arquitectura, correcciones y pruebas | +| [client_config.toml.simple](client_config.toml.simple) | Configuración completa del cliente | +| [server_config.toml.simple](server_config.toml.simple) | Configuración completa del servidor | + +```bash +go build ./cmd/client +go build ./cmd/server +go test ./... +go vet ./... +``` + +## Licencia y agradecimientos + +CottenDNS es mantenido por **tajirax** y se distribuye bajo la [Licencia MIT](LICENSE). + +- **Amin Mahmoudi** — [MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN), arquitectura original. +- **NullRoute1970** — [StormDNS](https://github.com/nullroute1970/StormDNS), upstream directo. +- Gracias a todos los colaboradores, testers y usuarios que reportan condiciones reales de redes censuradas. diff --git a/README_RU.MD b/README_RU.MD new file mode 100644 index 0000000..d241f4f --- /dev/null +++ b/README_RU.MD @@ -0,0 +1,356 @@ +

+ Логотип CottenDNS +

+ +

CottenDNS

+ +

+ DNS-туннель с приоритетом скорости для цензурируемых, нестабильных сетей с большими потерями. +

+ +

+ Лицензия MIT + Сборка + Последний выпуск +

+ +

+ English · + فارسی · + Русский · + 简体中文 · + Español +

+ +

+ Скачать · + Инженерные заметки · + Профили · + Telegram +

+ +> [!IMPORTANT] +> CottenDNS предназначен для сетей, где обычные VPN и прокси заблокированы, но остаётся рабочий DNS-канал. DNS-туннель по своей природе медленнее обычного VPN. Цель проекта — максимальная полезная скорость и непрерывность соединения под цензурой. + +## Начать здесь + +| Задача | Раздел | +| --- | --- | +| Развернуть новый туннель | [Полное руководство](#полное-руководство-по-установке) | +| Понять выбор транспорта | [Автоматический режим](#автоматический-режим) | +| Выбрать профиль | [Профили](#профили) | +| Проверить сервер | [Мониторинг](#мониторинг) | +| Встроить Android engine | [Android и submodule](#android-и-submodule) | + +## Происхождение и авторы + +CottenDNS создан на основе работы двух проектов: + +1. **[MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN)**, автор **Amin Mahmoudi** — исходная малозатратная архитектура, ARQ, балансировка резолверов и мультиплексирование сессий. +2. **[StormDNS](https://github.com/nullroute1970/StormDNS)**, сопровождаемый **NullRoute1970** — прямой предшественник CottenDNS. +3. **CottenDNS**, сопровождаемый **tajirax**, расширяет эту основу и сохраняет совместимость с клиентами MasterDNS/StormDNS. + +CottenDNS — самостоятельный производный проект, а не заявление об авторстве исходной архитектуры. Проекты сохраняют наследование лицензии MIT; см. [LICENSE](LICENSE). + +## Как это работает + +```text +Приложение + │ + ▼ +Локальный SOCKS5 / TCP + │ + ▼ +Клиент CottenDNS + ├── UDP/53 ─────┐ + ├── TCP/53 ─────┤ + ├── DoT ────────┤──► DNS-резолвер ──► Сервер CottenDNS ──► Интернет + └── DoH ────────┘ +``` + +Клиент делит потоки на небольшие DNS-кадры, шифрует и при необходимости сжимает их, выбирает путь через резолвер и восстанавливает потери с помощью ARQ и Reed–Solomon FEC. Сервер собирает поток и устанавливает исходящее соединение напрямую, через внешний SOCKS5 либо к фиксированной TCP-цели. + +Встроенный SOCKS5 поддерживает TCP и универсальные UDP-ассоциации. Локальный DNS-сервер и кэш включаются по желанию. + +## Основные преимущества + +| Проблема сети | Поведение CottenDNS | +| --- | --- | +| UDP быстрее, но иногда отравляется или ограничивается | Рабочий UDP сохраняется; переключается только повреждённый резолвер | +| Пути резолверов сильно отличаются | Каждая пара `(резолвер, транспорт)` оценивается отдельно | +| Upload и download имеют разное качество | Раздельная статистика доставки, ошибок, RTT и достоверности | +| Ограниченный DNS MTU | MTU измеряется для каждого пути; пакет идёт только туда, где помещается | +| Большие потери | ARQ, ACK/NACK, немедленный replay, адаптивное дублирование и FEC | +| Поддельный ответ приходит слишком быстро | Запускается одна проверенная альтернативная попытка | +| Путь умер с пакетами в полёте | Неподтверждённые кадры переигрываются без новой сессии | +| Защитные копии перегружают канал | FEC, дублирование, replay и сканирование используют общий бюджет | +| Несколько путей равноценны | Одинарные bulk-пакеты распределяются между зрелыми путями | +| DPI распознаёт повторяющийся DNS | Ротация типов записей, изменение QNAME, случайные ID/cookie, DoT/DoH | + +## Автоматический режим + +Рекомендуемые настройки: + +```toml +CONFIG_PRESET = "speed" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +Клиент автоматически: + +- сначала использует UDP и не отказывается от него без причины; +- сравнивает UDP и TCP/53 отдельно для каждого резолвера; +- меняет только плохой или значительно более медленный путь; +- учитывает размер пакета, MTU, RTT, потери, направление и качество измерений; +- поддерживает резервные пути редкими проверками без конкуренции с трафиком; +- не складывает без необходимости FEC, сканирование и дублирование; +- запускает альтернативу при признаках DNS poisoning; +- переигрывает кадры до полного истечения ARQ RTO. + +`auto` не включает DoT/DoH скрытно. Они выбираются пользователем: + +```toml +RESOLVER_TRANSPORT = "dot" # или "doh" +``` + +Отдельные исключения: + +```toml +RESOLVER_TRANSPORT_PATHS = { "1.1.1.1" = "doh", "8.8.8.8" = "auto", "9.9.9.9" = "tcp" } +``` + +## Динамический сервер + +UDP, TCP/53, DoT и DoH используют один движок сессий. При: + +```toml +ENCRYPTION_AUTO_DETECT = true +``` + +сервер автоматически принимает клиентские методы 1–5. AES-128/192/256-GCM проверяются раньше устаревших неаутентифицированных методов. XOR и ChaCha20 дополнительно проверяются по состоянию сессии и строгой структуре MTU-запросов. Метод `0` без шифрования разрешается только при явной настройке сервера. + +Разные клиенты могут выбирать разные поддерживаемые шифры, транспорты, MTU, сжатие и параметры надёжности. Сервер применяет только свои безопасные пределы. + +## Полное руководство по установке + +Обычная схема: + +```text +[Устройство клиента] → [DNS-резолверы] → [делегированный поддомен на VPS] → [Интернет] +``` + +### Перед началом + +Потребуются домен с доступом к DNS-записям, Linux VPS с публичным IPv4 и `sudo`, открытые входящие UDP/53 и TCP/53 и клиент Windows, Linux или macOS. Порт 53 должен быть открыт и в локальном firewall, и в сетевом firewall провайдера VPS. + +> [!CAUTION] +> Порт 53 может занимать `systemd-resolved`, BIND, dnsmasq или панель управления. Перед установкой проверьте `sudo ss -lntup | grep ':53 '`. Не закрывайте SSH-порт. + +### Шаг 1 — Делегируйте короткий поддомен + +Создайте у DNS-провайдера домена: + +```dns-zone +ns.example.com. 300 IN A 203.0.113.10 +v.example.com. 300 IN NS ns.example.com. +``` + +Замените IP на адрес VPS. Не включайте CDN-проксирование для этих записей. Короткое имя оставляет больше места для полезной нагрузки DNS. + +Проверьте распространение: + +```bash +dig +short A ns.example.com +dig +short NS v.example.com +``` + +Ожидаются IP сервера и `ns.example.com.`. Если NS не виден, сначала исправьте делегирование. + +### Шаг 2 — Установите сервер + +```bash +sudo install -d -m 0755 /opt/cottendns +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash +``` + +В запросе установщика укажите `v.example.com`. Он скачивает release, создаёт конфигурацию и ключ, настраивает systemd, открывает UDP/TCP 53 в поддерживаемом firewall и проверяет `/healthz`. + +Основные файлы: + +```text +/opt/cottendns/server_config.toml +/opt/cottendns/encrypt_key.txt +``` + +Скопируйте ключ на клиент через безопасный канал: + +```bash +sudo cat /opt/cottendns/encrypt_key.txt +``` + +### Шаг 3 — Проверьте сервер + +```bash +systemctl is-active cottendns +curl -fsS http://127.0.0.1:9090/healthz +sudo ss -lnup | grep ':53 ' +sudo ss -lntp | grep ':53 ' +``` + +При ошибке: + +```bash +journalctl -u cottendns -n 100 --no-pager +``` + +Не публикуйте порт `9090`: мониторинг по умолчанию предназначен для localhost. + +### Шаг 4 — Настройте клиент + +Скачайте архив своей платформы из [Releases](https://github.com/TaJirax/cottenDNS/releases/latest). Оставьте бинарник, `client_config.toml` и `client_resolvers.txt` в одной папке. + +Минимальная безопасная конфигурация: + +```toml +DOMAINS = ["v.example.com"] +ENCRYPTION_KEY = "ключ-сервера" +DATA_ENCRYPTION_METHOD = 3 + +PROTOCOL_TYPE = "SOCKS5" +LISTEN_IP = "127.0.0.1" +LISTEN_PORT = 18000 + +CONFIG_PRESET = "speed" +STARTUP_MODE = "resolvers" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +`client_resolvers.txt`: + +```text +1.1.1.1 +8.8.8.8 +9.9.9.9 +``` + +Начинайте с небольшого разнообразного списка: огромный список плохих резолверов только замедляет сканирование. + +### Шаг 5 — Выполните первый запуск + +Linux: + +```bash +chmod +x ./CottenDns_Client_* +./CottenDns_Client_Linux_AMD64_vVERSION --config ./client_config.toml +``` + +Windows PowerShell: + +```powershell +.\CottenDns_Client_Windows_AMD64_vVERSION.exe --config .\client_config.toml +``` + +Клиент проверит доступность туннеля, MTU, задержку, потери и UDP/TCP-путь каждого резолвера. Для сканирования без запуска proxy добавьте `--scan-only`. + +Оставьте `STARTUP_MODE = "resolvers"` при каждом запуске. Доступность, +отравление, транспорт и MTU резолверов могут измениться между перезапусками. +`FAST_CONNECT = true` запускает соединение после появления небольшого +проверенного пула, а остальные резолверы проверяются в фоне без доверия старому +кэшу. + +### Шаг 6 — Проверьте SOCKS5 + +Укажите в приложении `SOCKS5 127.0.0.1:18000` или выполните: + +```bash +curl --proxy socks5h://127.0.0.1:18000 https://example.com/ +``` + +`socks5h` передаёт разрешение имени через proxy. + +### Безопасное обновление + +```bash +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash -s -- --upgrade +``` + +Обновление сохраняет конфигурацию и ключ и откатывается, если новая служба не проходит health check. Для клиента распакуйте новый release отдельно и не удаляйте старую папку, пока новый бинарник не пройдёт сканирование и SOCKS-тест. + +## Профили + +| Профиль | Назначение | +| --- | --- | +| `speed` | Основной режим для максимальной полезной скорости | +| `survival` | Большие потери и нестабильные резолверы | +| `tcp-survival` | UDP/53 почти не работает, TCP/53 доступен | +| `iran`, `china`, `russia`, `venezuela`, `cuba` | Стартовые клиентские настройки для региональных ограничений | +| `low-bandwidth` | Очень слабый upload или строгий лимит DNS-запросов | + +Это отправные точки, а не автоматическое определение страны. См. [CONFIG_PRESETS.md](CONFIG_PRESETS.md). + +## Мониторинг + +```bash +curl -s http://127.0.0.1:9090/healthz +curl -s 'http://127.0.0.1:9090/healthz?details=1' | jq +curl -s http://127.0.0.1:9090/metrics +``` + +Использование процесса: + +```bash +pid=$(systemctl show -p MainPID --value cottendns) +ps -p "$pid" -o pid,%cpu,%mem,rss,vsz,etime,cmd +top -p "$pid" +``` + +## Android и submodule + +Android-приложение может подключать этот репозиторий как Git submodule или CI-зависимость. Проверенный коммит движка: + +```text +8eea49d1267b1a41441c25c75e69e6f12ad0a11f +``` + +Движок компилируется для Android arm64, armv7, amd64 и 386 без CGO. Оболочка приложения должна сохранить рекомендуемые TOML-настройки. + +## Совместимость и безопасность + +- Новый клиент + новый сервер: все функции. +- Старые клиенты + новый сервер: поддерживаются, включая MasterDNS/StormDNS. +- Новый клиент + старый сервер: клиентская маршрутизация работает, но новые серверные функции недоступны. +- Для аутентифицированного шифрования используйте AES-GCM `3`, `4` или `5`. +- DoT/DoH скрывает участок клиент→резолвер, но не весь маршрут. +- Метаданные объёма и времени могут показать, что используется туннель. +- Не публикуйте `encrypt_key.txt`. +- Используйте проект только там, где вы имеете право это делать. + +## Документация и разработка + +| Документ | Содержание | +| --- | --- | +| [CONFIG_PRESETS.md](CONFIG_PRESETS.md) | Профили и настройка | +| [ENGINEERING_CHANGES.md](docs/ENGINEERING_CHANGES.md) | Архитектура, исправления и тесты | +| [client_config.toml.simple](client_config.toml.simple) | Все параметры клиента | +| [server_config.toml.simple](server_config.toml.simple) | Все параметры сервера | + +```bash +go build ./cmd/client +go build ./cmd/server +go test ./... +go vet ./... +``` + +## Лицензия и благодарности + +CottenDNS сопровождается **tajirax** и распространяется по [MIT License](LICENSE). + +- **Amin Mahmoudi** — [MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN), исходная архитектура. +- **NullRoute1970** — [StormDNS](https://github.com/nullroute1970/StormDNS), прямой upstream. +- Спасибо всем участникам и пользователям, предоставляющим данные из цензурируемых сетей. diff --git a/README_ZH.MD b/README_ZH.MD new file mode 100644 index 0000000..717daa3 --- /dev/null +++ b/README_ZH.MD @@ -0,0 +1,357 @@ +

+ CottenDNS 标志 +

+ +

CottenDNS

+ +

+ 面向审查、高丢包和不稳定网络的速度优先 DNS 隧道。 +

+ +

+ MIT 许可证 + 构建状态 + 最新版本 +

+ +

+ English · + فارسی · + Русский · + 简体中文 · + Español +

+ +

+ 下载 · + 工程记录 · + 配置预设 · + Telegram +

+ +> [!IMPORTANT] +> CottenDNS 用于普通 VPN 和代理被封锁、但仍存在可用 DNS 路径的网络。DNS 隧道天然比普通 VPN 慢。本项目追求的是审查环境下的最大可用速度和持续连接,而不是脱离实际的带宽宣传。 + +## 从这里开始 + +| 目标 | 章节 | +| --- | --- | +| 部署新的隧道 | [完整部署指南](#完整部署指南) | +| 了解自动传输选择 | [自动智能模式](#自动智能模式) | +| 选择配置预设 | [配置预设](#配置预设) | +| 查看服务器状态 | [监控](#监控) | +| 嵌入 Android 引擎 | [Android 与引擎子模块](#android-与引擎子模块) | + +## 项目来源与致谢 + +CottenDNS 建立在两个项目的工作之上: + +1. **[MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN)**,作者 **Amin Mahmoudi**:提供了低开销 DNS 隧道、自定义 ARQ、解析器负载均衡、会话复用和恶劣网络设计基础。 +2. **[StormDNS](https://github.com/nullroute1970/StormDNS)**,维护者 **NullRoute1970**:CottenDNS 的直接上游来源。 +3. **CottenDNS** 由 **tajirax** 维护,在此基础上扩展,并保留 MasterDNS/StormDNS 客户端兼容性。 + +CottenDNS 是独立的衍生项目,并不声称原始架构由本项目首创。项目延续 MIT 许可证,详见 [LICENSE](LICENSE)。 + +## 工作方式 + +```text +应用程序 + │ + ▼ +本地 SOCKS5 / TCP + │ + ▼ +CottenDNS 客户端 + ├── UDP/53 ─────┐ + ├── TCP/53 ─────┤ + ├── DoT ────────┤──► DNS 解析器 ──► CottenDNS 服务端 ──► 互联网 + └── DoH ────────┘ +``` + +客户端把数据流拆成适合 DNS 的小帧,进行加密和可选压缩,选择解析器与传输路径,并通过 ARQ 和可选 Reed–Solomon FEC 修复丢包。服务端重组数据流,并直接连接目标、通过上游 SOCKS5,或连接固定 TCP 目标。 + +内置 SOCKS5 支持 TCP 和通用 UDP association;本地 DNS 监听器与缓存为可选功能。 + +## 核心优势 + +| 网络问题 | CottenDNS 的处理方式 | +| --- | --- | +| UDP 很快但可能被污染或限速 | 保留可用 UDP,只切换受影响的解析器 | +| 每个解析器路径不同 | 独立评估每个 `(解析器, 传输)` | +| 上行和下行质量不同 | 分别记录送达率、失败、RTT 和置信度 | +| DNS MTU 很小 | 每条路径单独探测 MTU,按数据包大小路由 | +| 丢包严重 | ARQ、ACK/NACK、即时重放、自适应复制、FEC/Super-FEC | +| 伪造响应异常快速 | 把它视为预警,并竞争一个经过验证的备用路径 | +| 路径失效时仍有在途帧 | 不重建会话,立即在其他路径重放 | +| 可靠性机制挤占带宽 | FEC、复制、重放和探测共享统一预算 | +| 多条路径质量接近 | 单副本批量流量在成熟路径间条带化 | +| DPI 识别重复 DNS 形态 | 轮换记录类型、调整 QNAME、随机 ID/cookie、可选 DoT/DoH | + +## 自动智能模式 + +推荐默认值: + +```toml +CONFIG_PRESET = "speed" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +客户端会自动: + +- 优先尝试 UDP,并在其仍有价值时继续使用; +- 对每个解析器分别比较 UDP 和 TCP/53; +- 只切换失败或明显更慢的路径; +- 按数据包大小、MTU、RTT、丢包、方向和置信度选择路径; +- 用低开销后台预算保持备用路径温热; +- 避免 FEC、探测和不必要复制叠加; +- 在检测到 DNS poisoning 时竞争备用路径; +- 在完整 ARQ 超时之前重放在途帧。 + +`auto` 不会暗中启用 DoT/DoH。它们由用户明确选择: + +```toml +RESOLVER_TRANSPORT = "dot" # 或 "doh" +``` + +也可以按解析器覆盖: + +```toml +RESOLVER_TRANSPORT_PATHS = { "1.1.1.1" = "doh", "8.8.8.8" = "auto", "9.9.9.9" = "tcp" } +``` + +## 动态服务端 + +UDP、TCP/53、DoT 和 DoH 共用同一个原生会话引擎。启用: + +```toml +ENCRYPTION_AUTO_DETECT = true +``` + +后,服务端会自动接受客户端选择的有密钥方法 1–5。AES-128/192/256-GCM 总是在旧式非认证解码器之前检查。XOR 和 ChaCha20 还必须符合活动会话或严格的 MTU 请求语义。明文方法 `0` 只有在服务端明确配置为明文时才会启用。 + +不同客户端可以使用不同的受支持加密、传输、MTU、压缩和可靠性参数,而无需重新配置服务端。服务端仍会执行自己的安全上限。 + +## 完整部署指南 + +常规部署路径: + +```text +[客户端设备] → [递归 DNS 解析器] → [VPS 上的委派子域] → [互联网] +``` + +### 开始之前 + +你需要:可修改 DNS 记录的域名、拥有公网 IPv4 和 `sudo` 权限的 Linux VPS、已放行的 UDP/53 与 TCP/53,以及 Windows、Linux 或 macOS 客户端。VPS 系统防火墙和云厂商安全组都必须允许 53 端口。 + +> [!CAUTION] +> `systemd-resolved`、BIND、dnsmasq 或服务器面板可能已经占用 53 端口。安装前运行 `sudo ss -lntup | grep ':53 '`。同时保留 SSH 管理端口,避免因防火墙修改而失联。 + +### 第 1 步 — 委派一个短 DNS 子域 + +在 `example.com` 的 DNS 提供商处创建: + +```dns-zone +ns.example.com. 300 IN A 203.0.113.10 +v.example.com. 300 IN NS ns.example.com. +``` + +将 IP 替换为 VPS 公网地址。不要为这两条记录启用 CDN 代理。较短的隧道域名可为每个 DNS 数据包保留更多有效载荷。 + +等待记录生效后检查: + +```bash +dig +short A ns.example.com +dig +short NS v.example.com +``` + +应分别返回服务器 IP 和 `ns.example.com.`。如果没有 NS 结果,请先修复委派,客户端调优无法弥补错误的 DNS 记录。 + +### 第 2 步 — 安装服务端 + +```bash +sudo install -d -m 0755 /opt/cottendns +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash +``` + +安装器询问域名时输入 `v.example.com`。它会下载 release、创建配置和密钥、设置 systemd、在支持的本机防火墙中开放 UDP/TCP 53,并检查 `/healthz`。 + +重要文件: + +```text +/opt/cottendns/server_config.toml +/opt/cottendns/encrypt_key.txt +``` + +通过安全渠道把密钥复制到客户端: + +```bash +sudo cat /opt/cottendns/encrypt_key.txt +``` + +不要把密钥放进截图、Issue、公共配置或解析器列表。 + +### 第 3 步 — 验证服务端 + +```bash +systemctl is-active cottendns +curl -fsS http://127.0.0.1:9090/healthz +sudo ss -lnup | grep ':53 ' +sudo ss -lntp | grep ':53 ' +``` + +如果失败: + +```bash +journalctl -u cottendns -n 100 --no-pager +``` + +监控端口 `9090` 默认仅供本机使用,不应直接暴露到公网。 + +### 第 4 步 — 配置客户端 + +从 [Releases](https://github.com/TaJirax/cottenDNS/releases/latest) 下载与系统和 CPU 架构匹配的客户端。将可执行文件、`client_config.toml` 和 `client_resolvers.txt` 保持在同一目录。 + +先使用最小安全配置: + +```toml +DOMAINS = ["v.example.com"] +ENCRYPTION_KEY = "服务端密钥" +DATA_ENCRYPTION_METHOD = 3 + +PROTOCOL_TYPE = "SOCKS5" +LISTEN_IP = "127.0.0.1" +LISTEN_PORT = 18000 + +CONFIG_PRESET = "speed" +STARTUP_MODE = "resolvers" +RESOLVER_TRANSPORT = "auto" +PATH_CONTROLLER_MODE = "unified" +COMPARABLE_PATH_STRIPING = true +``` + +`client_resolvers.txt`: + +```text +1.1.1.1 +8.8.8.8 +9.9.9.9 +``` + +先使用少量、来源不同的解析器。大量低质量地址只会延长扫描时间。 + +### 第 5 步 — 首次扫描和启动 + +Linux: + +```bash +chmod +x ./CottenDns_Client_* +./CottenDns_Client_Linux_AMD64_vVERSION --config ./client_config.toml +``` + +Windows PowerShell: + +```powershell +.\CottenDns_Client_Windows_AMD64_vVERSION.exe --config .\client_config.toml +``` + +客户端会测量每个解析器的隧道可达性、MTU、延迟、丢包以及 UDP/TCP 路径。只扫描而不启动代理时可添加 `--scan-only`。 + +每次启动都应保持 `STARTUP_MODE = "resolvers"`。解析器可达性、污染状态、 +传输质量和 MTU 可能在重启后发生变化,因此不能信任旧缓存。 +`FAST_CONNECT = true` 会在得到一个小型已验证解析器池后先建立连接, +其余解析器继续在后台进行全新验证。 + +### 第 6 步 — 测试 SOCKS5 + +在应用中设置 `SOCKS5 127.0.0.1:18000`,或运行: + +```bash +curl --proxy socks5h://127.0.0.1:18000 https://example.com/ +``` + +`socks5h` 会让域名解析也通过代理完成。 + +### 安全升级 + +```bash +cd /opt/cottendns +curl -fsSL https://raw.githubusercontent.com/TaJirax/cottenDNS/main/server_linux_install.sh | sudo bash -s -- --upgrade +``` + +服务端升级会保留配置和密钥;如果新服务未通过健康检查,会自动回滚。升级桌面客户端时,请先解压到新目录并复制配置,完成扫描和 SOCKS 测试后再删除旧版本。 + +## 配置预设 + +| 预设 | 适用场景 | +| --- | --- | +| `speed` | 默认选择,优先最大有效吞吐 | +| `survival` | 高丢包、不稳定解析器或强审查 | +| `tcp-survival` | UDP/53 基本不可用,但 TCP/53 可用 | +| `iran`, `china`, `russia`, `venezuela`, `cuba` | 常见地区限制的客户端起点 | +| `low-bandwidth` | 上行极弱或 DNS 查询预算很小 | + +预设只是起点,不是自动国家识别。ISP、城市、时间和审查事件都会改变实际情况。详见 [CONFIG_PRESETS.md](CONFIG_PRESETS.md)。 + +## 监控 + +```bash +curl -s http://127.0.0.1:9090/healthz +curl -s 'http://127.0.0.1:9090/healthz?details=1' | jq +curl -s http://127.0.0.1:9090/metrics +``` + +查看进程资源: + +```bash +pid=$(systemctl show -p MainPID --value cottendns) +ps -p "$pid" -o pid,%cpu,%mem,rss,vsz,etime,cmd +top -p "$pid" +``` + +## Android 与引擎子模块 + +Android 应用可以把本仓库作为 Git submodule 或 CI 依赖。已验证的引擎提交: + +```text +8eea49d1267b1a41441c25c75e69e6f12ad0a11f +``` + +引擎已在禁用 CGO 的情况下通过 Android arm64、armv7、amd64 和 386 编译。Android 外壳应保留推荐 TOML 默认值。 + +## 兼容性与安全 + +- 新客户端 + 新服务端:完整功能。 +- 旧客户端 + 新服务端:支持,包括 MasterDNS/StormDNS。 +- 新客户端 + 旧服务端:客户端路由改进可用,但缺少新服务端功能。 +- 认证加密请使用 AES-GCM 方法 `3`、`4` 或 `5`。 +- DoT/DoH 只保护客户端到解析器这一段。 +- 流量大小和时间特征仍可能暴露隧道行为。 +- 不要公开 `encrypt_key.txt`。 +- 仅在获得授权的环境中使用。 + +## 文档与开发 + +| 文档 | 内容 | +| --- | --- | +| [CONFIG_PRESETS.md](CONFIG_PRESETS.md) | 速度、生存和地区预设 | +| [ENGINEERING_CHANGES.md](docs/ENGINEERING_CHANGES.md) | 架构、修复和测试 | +| [client_config.toml.simple](client_config.toml.simple) | 完整客户端配置 | +| [server_config.toml.simple](server_config.toml.simple) | 完整服务端配置 | + +```bash +go build ./cmd/client +go build ./cmd/server +go test ./... +go vet ./... +``` + +## 许可证与致谢 + +CottenDNS 由 **tajirax** 维护,并按 [MIT License](LICENSE) 发布。 + +- **Amin Mahmoudi** — [MasterDnsVPN](https://github.com/masterking32/MasterDnsVPN),原始架构和项目基础。 +- **NullRoute1970** — [StormDNS](https://github.com/nullroute1970/StormDNS),直接上游来源。 +- 感谢所有贡献者、测试者以及提供审查网络反馈的用户。 diff --git a/build.py b/build.py index c4ff12e..e88ed51 100644 --- a/build.py +++ b/build.py @@ -12,22 +12,7 @@ def get_version(): except Exception: return "local-dev" -def resolve_c_compiler(env): - try: - cc = subprocess.check_output(["go", "env", "CC"], env=env, text=True).strip() - except Exception: - return None - - if not cc: - return None - - cc_bin = cc.split()[0].strip('"') - if shutil.which(cc_bin) is None: - return None - - return cc - -def build(goos, goarch, goarm, component, output_name, require_cgo=False): +def build(goos, goarch, goarm, component, output_name): print(f"Building {component} for {goos}/{goarch}...") env = os.environ.copy() @@ -35,16 +20,7 @@ def build(goos, goarch, goarm, component, output_name, require_cgo=False): env["GOARCH"] = goarch if goarm: env["GOARM"] = goarm - env["CGO_ENABLED"] = "1" if require_cgo else "0" - - if require_cgo: - cc = resolve_c_compiler(env) - if not cc: - print( - f"Skipping {component} for {goos}/{goarch}: " - "cgo toolchain not found (set CC to an Android cross-compiler)." - ) - return "skipped" + env["CGO_ENABLED"] = "0" version = get_version() ldflags = f"-s -w -X cottendns-go/internal/version.BuildVersion={version}" @@ -73,13 +49,9 @@ def main(): targets = [ {"os": "linux", "arch": "amd64", "ext": "", "platform": "Linux"}, {"os": "windows", "arch": "amd64", "ext": ".exe", "platform": "Windows"}, - {"os": "android", "arch": "arm64", "ext": "", "platform": "Termux"}, - {"os": "android", "arch": "arm", "goarm": "7", "ext": "", "platform": "Termux", "require_cgo": True}, ] failed = [] - skipped = [] - for t in targets: for component in ["client", "server"]: output_name = f"dist/CottenDns_{component.capitalize()}_{t['platform']}_{t['arch']}{t['ext']}" @@ -89,12 +61,9 @@ def main(): t.get("goarm"), component, output_name, - t.get("require_cgo", False), ) if result == "failed": failed.append(f"{component}:{t['os']}/{t['arch']}") - elif result == "skipped": - skipped.append(f"{component}:{t['os']}/{t['arch']}") if failed: print("Build failed for:", ", ".join(failed)) @@ -102,6 +71,7 @@ def main(): print("Copying config files...") shutil.copy("client_config.toml.simple", dist_dir / "client_config.toml") + shutil.copy("client_resolvers.simple", dist_dir / "client_resolvers.txt") shutil.copy("server_config.toml.simple", dist_dir / "server_config.toml") for preset in Path(".").glob("client_config.*.toml"): shutil.copy(preset, dist_dir / preset.name) @@ -121,9 +91,6 @@ def main(): if engineering_notes.exists(): shutil.copy(engineering_notes, dist_dir / "ENGINEERING_CHANGES.md") - if skipped: - print("Skipped targets:", ", ".join(skipped)) - print("Build complete.") if __name__ == "__main__": diff --git a/docs/ENGINEERING_CHANGES.md b/docs/ENGINEERING_CHANGES.md index 6b46acc..d688c67 100644 --- a/docs/ENGINEERING_CHANGES.md +++ b/docs/ENGINEERING_CHANGES.md @@ -7,16 +7,14 @@ helps** on hostile DNS networks. Honest caveats are called out where they exist. --- -## Android engine ownership and embedding +## External mobile engine boundary -The Android-facing engine is now maintained in this repository instead of as a -modified source copy inside the app. Android CI checks out an immutable -CottenDNS revision and builds all four ABIs with -`scripts/build-android-client.sh`. This removes the source/binary drift that can -make branch builds work while merged-main builds silently package an older -engine. +This repository owns the portable engine source only. Mobile repositories pin +and import a reviewed CottenDNS revision, then own all Android compilation, +packaging, signing, and release logic. CottenDNS intentionally ships no Android +build scripts or binary artifacts. -The standalone client includes the app integration contract: +The portable client source includes the engine behavior: - Fast Connect releases startup after a safe MTU-tested pool is available, throttles the remaining scan to background priority, and promotes newly found @@ -31,9 +29,6 @@ The standalone client includes the app integration contract: - Generic UDP, fallback to the DNS-specific UDP path, loss recovery, adaptive duplication, and server fairness remain in the same versioned source. -The Android integration and pinned-revision rules are documented in -`docs/ANDROID_ENGINE_INTEGRATION.md`. - --- ## 0. System model (read this first) @@ -228,7 +223,18 @@ change its encryption method without the server being reconfigured. (3–5 are AEAD). With `ENCRYPTION_AUTO_DETECT` (default true), the server builds a codec set and trial-decrypts each inbound frame, **AEAD methods first** (they authenticate, so they cannot be mis-detected), falling back to the unauthenticated -ciphers. The first codec that yields a valid frame is used. +ciphers. Runtime preference is preserved inside each security class, but it can +never move XOR, ChaCha20, or plaintext ahead of an AES-GCM candidate. + +The unauthenticated legacy methods need an additional rule: a one-byte header +check alone cannot prove which stream cipher produced a frame. Their decoded +candidates are therefore checked against live session ID/cookie/layout state. +Pre-session MTU candidates also have their response mode, requested download +size, and zero-filled capacity padding validated before a legacy codec may claim +them. If an old or malformed pre-session frame matches none of those stricter +rules, the historical structural fallback remains available, preserving old +MasterDNS/StormDNS admission rather than turning hardening into a compatibility +break. **Why it helps.** A client can pick a rarer/stronger cipher (or rotate) and the server simply reads it. AEAD-first ordering avoids false positives from the @@ -389,10 +395,11 @@ length-prefixed, routed through the **exact same** transport-agnostic load-shedding, graceful shutdown — so all tunnel logic (sessions, FEC, channels, encryption) is shared with UDP, no duplication. -**Client.** Client-wide transport via `RESOLVER_TRANSPORT = auto | udp | tcp`: -- **`auto` (default)** probes over UDP first; if **zero** resolvers pass MTU - testing, it flips to TCP and **re-probes the whole fleet over TCP/53**. On a - UDP-working network TCP is never attempted (zero cost). +**Client.** Resolver-local transport policy via +`RESOLVER_TRANSPORT = auto | udp | tcp`: +- **`auto` (default)** measures UDP and TCP/53 for every resolver, then keeps + each resolver on its fastest healthy path. A bad UDP path can switch without + moving the rest of the fleet. - A `queryExchanger` abstraction makes the probe, session-init, and health paths transport-agnostic. - A persistent **per-resolver TCP connection manager** (`tcp_data.go`) serves the @@ -666,8 +673,9 @@ doh ─► UDP ─► TCP/53 tcp ─► (no fallback) auto ─► UDP ─► TCP/53 ``` -The chain is `resolverTransportChain()`; the walk is in `RunInitialMTUTests`, -which re-probes the whole fleet on each step down. +The chain is `resolverTransportChain()` and MTU discovery walks it independently +for each resolver. The background scanner keeps alternate paths measured after +startup. ### 17.2 How they are wired into the data path @@ -707,10 +715,9 @@ from the entry plus the transport's own port/path, so `1.1.1.1` becomes `https://1.1.1.1:443/dns-query`. Cloudflare, Google and Quad9 publish certificates carrying their **IP as a SAN**, so (A) validates with no configuration at all. -*Caveat:* the transport and its port/path are client-wide, not per-resolver. You -cannot run one resolver over DoH while another stays on UDP, and providers using a -different path cannot be mixed in one profile. The hedging is sequential -(fallback), not parallel. +Per-resolver overrides were added later in §25. The encrypted transport's +port/path and TLS identity remain shared, but individual resolvers can now select +`auto`, `udp`, `tcp`, `dot`, or `doh`. ### 17.4 Certificate trust @@ -1115,7 +1122,267 @@ than merely accepting it as a configuration value. --- +## 25. Poison-aware per-resolver transport and path MTU + +Transport selection is now resolver-local instead of a whole-client fallback. +`RESOLVER_TRANSPORT_PATHS` can override the global policy by connection key, +resolver label, IP:port, or IP. `auto` measures UDP and TCP/53 for each resolver; +DoT and DoH remain explicit user choices and retain UDP/TCP survival fallbacks. + +Initial MTU discovery probes every configured path separately and stores its RTT, +loss, upload MTU, and download MTU. A bounded background scanner performs full +MTU discovery on one rotating resolver/transport path at a time at +`RESOLVER_TRANSPORT_BACKGROUND_SCAN_INTERVAL_SECONDS`, keeping alternate paths +fresh without creating a scan burst or competing materially with user traffic. +Selection uses estimated delivered goodput and will move a resolver away from a +slow, failing, poisoned, or session-MTU-incompatible path. Bulk packets use only +the best path; sparse high-priority/control traffic may hedge one alternate at a +bounded interval, so duplication cannot cap bulk throughput. + +The runtime scheduler now scores `(resolver, transport)` jointly for the actual +native packet type and payload size. MTU probe capacity is normalized for each +packet header before eligibility is checked. A resolver or transport below the +global session MTU is therefore not dead capacity: it can carry ACKs, controls, +and any data fragment that fits, while larger fragments remain on wider paths. +Stream affinity receives a small stability bias, not a hard pin, preventing +reordering between equivalent paths without trapping a stream on a materially +slower route. Bulk data never uses an unmeasured transport. + +If local UDP transmission, persistent TCP/DoT dialing/writing, or a DoH exchange +fails, the exact unacknowledged DNS query is replayed immediately on the best +eligible alternate resolver/transport. Replay preserves the native frame, +session, and sequence identity, is capped at two path hops, and does not wait for +the full ARQ RTO. ARQ/NACK remains the correctness backstop after the bounded +fast replay is exhausted. + +Inbound replies are bound to resolver address, local socket, transport, query ID, +and the complete DNS question. Same-ID replies carrying a different question are +treated as injection and ignored. When injected NXDOMAIN filtering is enabled, +the forged answer no longer consumes the outstanding request, allowing a genuine +answer arriving moments later to win. Poison is evidence that an alternate path +must be compared, not an automatic penalty against a UDP path that remains the +fastest working option. + +Question fingerprints canonicalize ASCII letter case because DNS names are +case-insensitive; resolvers that normalize 0x20 casing are accepted without +weakening QTYPE/QCLASS or name matching. A UDP reply carrying `TC=1` replays the +affected request immediately, but UDP is displaced only after repeated +truncation evidence without an intervening valid reply. Poison evidence +accelerates a following timeout for two minutes, then expires so an old incident +cannot make a clean future network overly sensitive. + +An unusually fast forged response also acts as a Happy-Eyeballs trigger. The +still-pending query is raced once on the best alternate path; the original is not +cancelled, and the first response that passes DNS-question and native tunnel +authentication atomically claims all replay siblings. Existing control hedges +count as the alternate, preventing poison from causing duplicate amplification. + +Background path exploration is congestion-aware and capacity-budgeted. One +rotating full MTU refresh is charged per 4096 original foreground frames, a +conservative approximately 1-2% allowance using a 64-query scan cost model. +No scan starts when TX, encoded-TX, RX, or pending-query pressure is elevated. +Completely idle paths receive a stale-state refresh no more often than every two +minutes (or four configured scan intervals), but even that exception yields to a +single queued user packet. + +The final speed audit ranks replay candidates across the complete eligible +`(resolver, transport)` set instead of preferring the same resolver by default. +The common non-duplicated response path no longer scans the complete pending map, +DNS question fingerprints are parsed once per normal ingress, and immutable +encoded DNS frames are retained rather than copied into each pending/stream +queue. Transport-manager publication is protected during runtime teardown. +Poison/timeout correlation also accepts the timeout deadline being a few +milliseconds earlier than the adjacent poison event, eliminating an +event-ordering-dependent missed fast switch found by repeated race testing. + +After three successful samples, pending-query blackhole detection uses a +conservative RTT-derived deadline (`6 × RTT + 500 ms`, with a 1.5-second floor +and the configured request timeout as its ceiling). Slow/high-jitter paths retain +the configured timeout. Late genuine replies remain claimable during the +existing grace period and retract their timeout observation. + +The server remains transport-agnostic: UDP, TCP, DoT, and DoH feed the same +authenticated native packet handler and keep the client's selected settings +dynamic. The server's Super-FEC band now chooses enough parity for a 90% modeled +block-recovery target, within Reed-Solomon and configured caps. Randomized loss +tests exercise actual encoding and reconstruction at 40% and 84% loss; ARQ +remains the correctness backstop when a block exceeds its parity budget. + +--- + +## 26. Unified directional path and redundancy controller + +`PATH_CONTROLLER_MODE = "unified"` coordinates the client-side mechanisms that +previously made partially independent decisions. It adds no protocol fields and +requires no server change. `PATH_CONTROLLER_MODE = "legacy"` immediately +restores the preceding behavior for field rollback. + +Each resolver/transport now retains separate authenticated upload and download +delivery EWMAs, sample confidence, RTT, and failure streaks. A failed ACK poll no +longer reduces the score of an otherwise healthy upload path, and one fast sample +cannot outrank a mature path. Packet-size MTU eligibility and the existing +transport hysteresis remain mandatory gates. + +The controller produces one decision per packet. During queue pressure it +suppresses only copies added by adaptive duplication, never the configured base. +Recent download FEC retains the established two-poll diversity cap, and healthy +exploration cannot stack on FEC or existing duplication. Poison/failure recovery +hedges remain available for high-priority traffic, while ARQ remains the +eventual-delivery backstop. + +With `COMPARABLE_PATH_STRIPING = true`, successive bulk upload packets may rotate +across at most four resolver paths. A candidate needs at least three +authenticated directional samples, at least 85% of the primary delivered score, +and RTT within 35% of the primary. Striping sends exactly one copy, stops when +queues are congested, and never admits an unknown or materially slower path. +Weighted multiplicative stepping avoids long bursts on one resolver. + +Traffic telemetry reports `stripe` decisions and `saved` redundant copies beside +exploration and transport-switch counters. This makes the controller observable +without adding probe traffic. + +--- + +## 27. Final controller and dynamic-encryption validation + +The final bug hunt found that rotating the server's last-successful codec index +could put an unauthenticated decoder ahead of an authenticated one. A random +legacy decode occasionally passed the compact header check, causing admission +telemetry to credit the wrong method. Codec iteration is now two-phase and +allocation-free: every AES-GCM candidate is exhausted first, followed by the +legacy class in preferred order. Established legacy candidates must match active +session state, and MTU discovery uses the semantic checks described in section 5. + +Dynamic server behavior was tested as a response-producing matrix, not merely as +"packet accepted": every enabled encryption method was sent through UDP, TCP/53, +DoT, and DoH, and the returned native MTU response had to contain the original +four-byte verification nonce. A keyed server accepted methods 1-5; a server +explicitly configured to allow plaintext accepted methods 0-5. The plaintext +method remains excluded when the configured server method is encrypted. + +Validation on the 2026-07-29 working tree: + +- complete uncached repository tests and `go vet`; +- complete repository race detector; +- hostile-network race matrix repeated 20 times, covering poison, question + hijack, in-flight replay, MTU path selection, UDP/TCP/DoT/DoH, and 40%/75%/84% + FEC reconstruction; +- mixed codec admission repeated 200 times and the response-producing + transport/encryption matrix repeated 10 times; +- country, legacy, and native profile loading repeated 50 times; +- shuffled complete client tests repeated 10 times; +- Linux/Windows client and server packaging through `build.py`; and +- all engine packages cross-compiled for Android arm64, armv7, amd64, and 386 + with CGO disabled. + +The unified controller's clean decision costs 19.42-19.95 ns with zero +allocations. The sixteen-resolver joint selector measures 3.48-3.57 microseconds +with three allocations. Under congestion, an adaptive five-copy decision is +reduced to the configured one-copy base (80% fewer redundant frames). In a +two-comparable-path simulation, 1000 one-copy bulk packets split 526/474 without +duplication. Directional evidence retained 100% of a healthy direction's score +after the opposite direction failed; legacy shared evidence retained 29.2%. + +The local one-resolver end-to-end A/B result is intentionally conservative: +unified and legacy modes were within loopback noise (median upload +0.8%, +download +0.5% for unified). The controller is not claiming artificial +single-path bandwidth. Its measurable gain appears when paths diverge or queues +fill: it avoids redundant congestion, stops cross-direction false demotion, and +uses comparable capacity without adding copies. + +--- + *All changes keep ARQ as the correctness backstop; every optimization above is designed to fail safe — if FEC, MTU grouping, a carrier, or a transport channel does not help on a given path, the tunnel still delivers through the surviving resolver/path combination.* + +## 28. Fresh resolver validation on every launch + +Resolver reachability, poisoning behavior, transport quality, loss, and MTU can +change between two process launches on the target networks. Reusing a +previous-run resolver cache could therefore start the client on paths that are +no longer usable and could exclude newly useful resolvers. + +Cache-assisted startup is now disabled at every supported entrypoint: + +- the command-line client always bootstraps from the current resolver source; +- default, legacy `ask`, and legacy `logs` startup values normalize to + `resolvers`; +- `BootstrapFromLogs` remains source-compatible for older desktop/Android + wrappers but intentionally delegates to normal fresh bootstrap and ignores + cache entries; +- resolver-mode MTU retry/timeout/parallelism values are always selected; +- the Linux service installer rewrites old `ask`/`logs` values to `resolvers`; + and +- resolver-cache log files remain diagnostic output only. + +`FAST_CONNECT` preserves good startup experience without stale state: the client +releases a newly validated starter pool, then continues scanning the remaining +current-list resolvers in the background at bounded parallelism. No resolver is +accepted because it worked in a previous process. + +## 29. Sustained DNS-over-TCP flow control + +Small interactive exchanges could succeed over TCP/53 while sustained media +traffic filled the fixed stream-transport queue. The old non-blocking admission +path then discarded newly encoded frames, leaving ARQ to recover them later. +Combined with unrestricted DNS pipelining and TCP head-of-line blocking, this +could turn a temporary resolver slowdown into a retransmission loop. + +The TCP/53 and DoT data manager now applies lossless backpressure: + +- a full stream queue pauses upstream writers instead of dropping fresh frames; +- each persistent connection permits at most 32 unanswered DNS queries; +- the historical two connection stripes remain the clean-path baseline; +- queue pressure raises the stripe count gradually to four, six, and at most + eight, returning to the two-stripe decision when pressure clears; +- a connection with no response progress is closed so blocked senders wake and + the existing bounded cross-path replay can recover; and +- DNS-over-TCP framing completes partial socket writes instead of assuming one + `Write` call consumed the complete message. + +The server now handles up to 32 pipelined queries concurrently per TCP/DoT +connection. Response writes remain serialized at DNS-message boundaries, and +the existing global/per-IP connection budgets still bound overload. No native +packet, encryption, session, carrier, or legacy wire format changed. + +Focused tests cover queue backpressure, shutdown cancellation, inflight-window +release, pressure-based stripe scaling, partial writes, and concurrent +server-side pipelining. The full repository tests, `go vet`, native +client/server builds, and Android engine cross-builds for arm64, armv7, amd64, +and 386 pass with CGO disabled. Windows race execution was unavailable on this +workstation because its configured MinGW compiler path no longer exists; the +CI release matrix remains the authoritative clean-environment build check. + +## 30. Busy-tunnel UDP restoration + +An availability failure could demote a resolver from configured-first UDP to +TCP, after which the resolver could remain on TCP for the whole session. The +full background MTU sweep correctly yields whenever foreground traffic is +active, while ordinary healthy exploration rejects a path already marked +non-viable. Reducing the generic 1/1024 exploration interval alone therefore +could not repair the ratchet. + +Availability restoration now has a separate authenticated foreground channel: + +- one restoration ticket accrues per 64 original DNS frames (at most 1.5625% + additional queries in a single-copy configuration); +- when duplication already exists, one duplicate is replaced by the canary, so + the query count does not increase; +- only control/setup frames are eligible, and existing FEC or another hedge + prevents stacking; +- restoration remains active under ordinary sustained load but stops at 75% + queue occupancy; +- failed/non-viable configured-first paths are eligible, which is essential + after a startup UDP miss; +- two authenticated configured-first wins are required, a failure resets the + evidence, and the ten-second speed-switch cooldown prevents flapping; and +- full idle MTU discovery remains available for exact path remeasurement. + +A deterministic 48-resolver sustained-load test restores the complete fleet +from TCP to UDP in 6,144 foreground frames using 96 authenticated canaries. The +measured worst-case query ratio is 96/6,144 = 1.5625%; the duplicated-path test +keeps three configured copies at exactly three while substituting one UDP +canary. Moderate 25% queue occupancy still permits restoration, while 75% +occupancy suppresses it. Focused and complete client tests pass. diff --git a/internal/client/client.go b/internal/client/client.go index e3b9ebd..87e3330 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -77,6 +77,9 @@ type Client struct { resolverRuntimeLogMu sync.Mutex lastResolverRuntimeLog string lastResolverRuntimeLogAt time.Time + mtuProgressLogMu sync.Mutex + lastMTUProgressPercent int + lastMTUProgressAt time.Time // MTU States mtuStateMu sync.Mutex diff --git a/internal/client/mtu.go b/internal/client/mtu.go index d5f90a9..774bb86 100644 --- a/internal/client/mtu.go +++ b/internal/client/mtu.go @@ -38,6 +38,12 @@ const ( // 1/8 = 12.5%) before the session adopts it, so flapping resolvers do not // churn the session MTU. Stranded (unsustainable) points always move. mtuHysteresisDivisor = 8 + // The MTU scan owns the middle of the connect progress bar: it starts where + // the "starting" phase leaves off and stops below the "selecting" phase, so + // the bar only ever moves forward. + mtuProgressStartPercent = 10 + mtuProgressSpanPercent = 70 + mtuProgressInterval = 250 * time.Millisecond ) var ( @@ -639,6 +645,9 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn *Connection, ser if conn == nil { return } + // Registered before the recover below so it runs after it: every exit path, + // panic included, has updated the counters by the time progress is reported. + defer c.logMTUProgress(counters, total) defer func() { if recovered := recover(); recovered != nil { c.mtuStateMu.Lock() diff --git a/internal/client/mtu_logging.go b/internal/client/mtu_logging.go index 8f42676..ae96339 100644 --- a/internal/client/mtu_logging.go +++ b/internal/client/mtu_logging.go @@ -49,6 +49,66 @@ func (c *Client) logConnectionProgress(phase string, percent int, keyValues ...a c.log.Machinef("%s", b.String()) } +// logMTUProgress reports scan progress for the desktop app, which draws its +// connection progress bar from these lines. The MTU scan is by far the longest +// phase of a connect, so without it the bar sits at the "starting" percent and +// then jumps straight to "selecting" when the scan ends. +func (c *Client) logMTUProgress(counters *mtuScanCounters, total int) { + if counters == nil || total < 0 { + return + } + completed := int(counters.completed.Load()) + valid := int(counters.valid.Load()) + rejected := int(counters.rejectUpload.Load() + counters.rejectDownload.Load()) + percent := mtuProgressStartPercent + if total > 0 { + percent += (mtuProgressSpanPercent * completed) / total + } + if !c.shouldLogMTUProgress(completed, total, percent) { + return + } + c.logConnectionProgress( + "mtu", + percent, + "completed", completed, + "total", total, + "valid", valid, + "rejected", rejected, + ) +} + +func (c *Client) resetMTUProgressThrottle() { + if c == nil { + return + } + c.mtuProgressLogMu.Lock() + c.lastMTUProgressPercent = -1 + c.lastMTUProgressAt = time.Time{} + c.mtuProgressLogMu.Unlock() +} + +// shouldLogMTUProgress holds the machine output to one line per percent step, +// and never drops the first or last line of a scan. +func (c *Client) shouldLogMTUProgress(completed, total, percent int) bool { + if c == nil { + return true + } + now := c.now() + c.mtuProgressLogMu.Lock() + defer c.mtuProgressLogMu.Unlock() + if completed == 0 || (total > 0 && completed >= total) { + c.lastMTUProgressPercent = percent + c.lastMTUProgressAt = now + return true + } + if c.lastMTUProgressPercent != percent || c.lastMTUProgressAt.IsZero() || now.Sub(c.lastMTUProgressAt) >= mtuProgressInterval { + c.lastMTUProgressPercent = percent + c.lastMTUProgressAt = now + return true + } + return false +} + func (c *Client) logMTUProbe(isRetry bool, background bool, format string, args ...any) { if isRetry || background || !c.mtuDebugEnabled() { return @@ -57,6 +117,7 @@ func (c *Client) logMTUProbe(isRetry bool, background bool, format string, args } func (c *Client) logMTUStart(workerCount int) { + c.resetMTUProgressThrottle() if !c.mtuInfoEnabled() { return } diff --git a/internal/client/mtu_progress_test.go b/internal/client/mtu_progress_test.go new file mode 100644 index 0000000..50608e9 --- /dev/null +++ b/internal/client/mtu_progress_test.go @@ -0,0 +1,80 @@ +package client + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "cottendns-go/internal/logger" +) + +// The desktop app draws its connection progress bar from WD_PROGRESS lines, so +// the MTU scan has to report as it goes. It must survive LOG_LEVEL=WARN, which +// suppresses the human-readable per-resolver lines. +func TestLogMTUProgressEmitsMachineLinesAtWarn(t *testing.T) { + path := filepath.Join(t.TempDir(), "log.txt") + log := logger.NewWithFile("test", "WARN", path) + t.Cleanup(func() { _ = log.Close() }) + + now := time.Now() + c := &Client{log: log, nowFn: func() time.Time { return now }} + c.resetMTUProgressThrottle() + + counters := &mtuScanCounters{} + total := 4 + + c.logMTUProgress(counters, total) // completed=0, always emitted + for i := 0; i < total; i++ { + counters.completed.Add(1) + if i%2 == 0 { + counters.valid.Add(1) + } else { + counters.rejectUpload.Add(1) + } + now = now.Add(mtuProgressInterval) + c.logMTUProgress(counters, total) + } + + out := readFile(t, path) + for _, want := range []string{ + "WD_PROGRESS phase=mtu percent=10 completed=0 total=4", + "WD_PROGRESS phase=mtu percent=27 completed=1 total=4 valid=1 rejected=0", + "WD_PROGRESS phase=mtu percent=80 completed=4 total=4 valid=2 rejected=2", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } +} + +// The scan runs one probe per resolver-domain pair and they finish in bursts, so +// unthrottled reporting would flood the log. Repeats within the interval that do +// not move the percent are dropped, but the final line never is. +func TestLogMTUProgressThrottlesRepeats(t *testing.T) { + path := filepath.Join(t.TempDir(), "log.txt") + log := logger.NewWithFile("test", "WARN", path) + t.Cleanup(func() { _ = log.Close() }) + + now := time.Now() + c := &Client{log: log, nowFn: func() time.Time { return now }} + c.resetMTUProgressThrottle() + + counters := &mtuScanCounters{} + counters.completed.Store(1) + total := 100 + + c.logMTUProgress(counters, total) + c.logMTUProgress(counters, total) + c.logMTUProgress(counters, total) + + if got := strings.Count(readFile(t, path), "phase=mtu"); got != 1 { + t.Fatalf("expected the repeats to be throttled to one line, got %d", got) + } + + counters.completed.Store(int32(total)) + c.logMTUProgress(counters, total) + if !strings.Contains(readFile(t, path), "completed=100 total=100") { + t.Fatal("the final progress line must never be throttled away") + } +} diff --git a/internal/config/presets.go b/internal/config/presets.go index f3bcbd5..8d446db 100644 --- a/internal/config/presets.go +++ b/internal/config/presets.go @@ -31,6 +31,19 @@ func isKnownConfigPreset(name string) bool { } } +// Server preset validation remains independent from the client preset set. +// Keep these helpers here because server.go uses them during final validation. +const serverConfigPresetNames = "default, speed, survival, tcp-survival" + +func isKnownServerConfigPreset(name string) bool { + switch normalizeConfigPresetName(name) { + case "default", "speed", "survival", "tcp-survival": + return true + default: + return false + } +} + func configKeyUnset(isDefined configKeyDefinedFunc, key string) bool { return isDefined == nil || !isDefined(key) } diff --git a/internal/config/server.go b/internal/config/server.go index 37004a7..210cb10 100644 --- a/internal/config/server.go +++ b/internal/config/server.go @@ -411,8 +411,8 @@ func LoadServerConfigWithOverrides(filename string, overrides ServerConfigOverri func finalizeServerConfig(cfg ServerConfig) (ServerConfig, error) { cfg.ConfigPreset = normalizeConfigPresetName(cfg.ConfigPreset) - if !isKnownConfigPreset(cfg.ConfigPreset) { - return cfg, fmt.Errorf("invalid CONFIG_PRESET: %q (valid: default, speed, survival, tcp-survival)", cfg.ConfigPreset) + if !isKnownServerConfigPreset(cfg.ConfigPreset) { + return cfg, fmt.Errorf("invalid CONFIG_PRESET: %q (valid: %s)", cfg.ConfigPreset, serverConfigPresetNames) } cfg.ProtocolType = defaultString(strings.ToUpper(strings.TrimSpace(cfg.ProtocolType)), "SOCKS5") diff --git a/internal/fec/burst_interleave_simulation_test.go b/internal/fec/burst_interleave_simulation_test.go new file mode 100644 index 0000000..67f1f2d --- /dev/null +++ b/internal/fec/burst_interleave_simulation_test.go @@ -0,0 +1,80 @@ +package fec + +import ( + "fmt" + "testing" +) + +// TestBurstInterleaveSimulation is an experiment, not a wire-format change. +// It quantifies whether round-robin transmission of already-framed FEC blocks +// is worth implementing before adding buffering to the live server path. +func TestBurstInterleaveSimulation(t *testing.T) { + const ( + blocks = 4 + dataShards = 4 + parity = 2 + burstStart = 6 + burstLen = 4 + ) + + encoder := NewEncoder(dataShards, parity) + byBlock := make([][][]byte, 0, blocks) + for block := 0; block < blocks; block++ { + var frames [][]byte + for packet := 0; packet < dataShards; packet++ { + var err error + frames, err = encoder.AddPacket([]byte(fmt.Sprintf("b%d-p%d", block, packet))) + if err != nil { + t.Fatalf("encode block %d: %v", block, err) + } + } + if len(frames) != dataShards+parity { + t.Fatalf("block %d emitted %d frames", block, len(frames)) + } + byBlock = append(byBlock, frames) + } + + sequential := make([][]byte, 0, blocks*(dataShards+parity)) + for _, frames := range byBlock { + sequential = append(sequential, frames...) + } + interleaved := make([][]byte, 0, cap(sequential)) + for shard := 0; shard < dataShards+parity; shard++ { + for block := 0; block < blocks; block++ { + interleaved = append(interleaved, byBlock[block][shard]) + } + } + + recoveredSequential := recoverOutsideBurst(t, sequential, burstStart, burstLen) + recoveredInterleaved := recoverOutsideBurst(t, interleaved, burstStart, burstLen) + t.Logf( + "four-frame burst: sequential recovered=%d/%d, interleaved recovered=%d/%d", + recoveredSequential, + blocks*dataShards, + recoveredInterleaved, + blocks*dataShards, + ) + if recoveredSequential != 3*dataShards { + t.Fatalf("sequential recovery=%d, want %d", recoveredSequential, 3*dataShards) + } + if recoveredInterleaved != blocks*dataShards { + t.Fatalf("interleaved recovery=%d, want %d", recoveredInterleaved, blocks*dataShards) + } +} + +func recoverOutsideBurst(t *testing.T, frames [][]byte, burstStart, burstLen int) int { + t.Helper() + decoder := NewDecoder() + recovered := 0 + for index, frame := range frames { + if index >= burstStart && index < burstStart+burstLen { + continue + } + packets, err := decoder.AddShard(frame) + if err != nil { + t.Fatalf("decode frame %d: %v", index, err) + } + recovered += len(packets) + } + return recovered +} diff --git a/internal/fec/fec.go b/internal/fec/fec.go index 9208fa4..4b22c9c 100644 --- a/internal/fec/fec.go +++ b/internal/fec/fec.go @@ -20,6 +20,7 @@ package fec import ( "encoding/binary" "errors" + "math" "github.com/klauspost/reedsolomon" ) @@ -178,3 +179,61 @@ func ParityForLoss(dataShards int, lossFrac float64) int { } return parity } + +// ParityForLossTarget returns enough parity for a requested block-recovery +// probability under independent random loss. ParityForLoss guarantees only the +// expected survivor count plus one shard; at extreme loss that succeeds in +// roughly half of random blocks. Super-FEC uses this stronger calculation so an +// 84% link has a useful recovery probability instead of a merely possible one. +func ParityForLossTarget(dataShards int, lossFrac, recoveryTarget float64) int { + if dataShards < 1 { + return 0 + } + if lossFrac < 0 { + lossFrac = 0 + } + if lossFrac > 0.95 { + lossFrac = 0.95 + } + if recoveryTarget <= 0 || recoveryTarget >= 1 { + recoveryTarget = 0.90 + } + minParity := ParityForLoss(dataShards, lossFrac) + for total := dataShards + minParity; total <= maxShards; total++ { + if shardRecoveryProbability(total, dataShards, 1-lossFrac) >= recoveryTarget { + return total - dataShards + } + } + return MaxParity(dataShards) +} + +// shardRecoveryProbability is P(X >= required) for X surviving shards out of +// total under independent survival probability p. +func shardRecoveryProbability(total, required int, p float64) float64 { + if required <= 0 { + return 1 + } + if total < required || p <= 0 { + return 0 + } + if p >= 1 { + return 1 + } + // Sum the failure tail P(X < required) using the binomial recurrence. With + // at most 256 shards this is stable and far cheaper than an encode. + q := 1 - p + term := math.Pow(q, float64(total)) // P(X=0) + failure := term + for k := 0; k < required-1; k++ { + term *= float64(total-k) / float64(k+1) * p / q + failure += term + } + recovery := 1 - failure + if recovery < 0 { + return 0 + } + if recovery > 1 { + return 1 + } + return recovery +} diff --git a/internal/fec/fec_test.go b/internal/fec/fec_test.go index be60ed4..e0d542f 100644 --- a/internal/fec/fec_test.go +++ b/internal/fec/fec_test.go @@ -10,6 +10,7 @@ package fec import ( "bytes" "fmt" + "math/rand" "testing" ) @@ -157,3 +158,90 @@ func TestParityForLossMonotonic(t *testing.T) { prev = p } } + +func TestLossyNetworkRecoveryEffectiveness(t *testing.T) { + tests := []struct { + name string + loss float64 + parity int + minRecovery float64 + }{ + { + name: "auto-fec-40-percent", + loss: 0.40, + parity: ParityForLoss(4, 0.40), + minRecovery: 0.75, + }, + { + name: "super-fec-84-percent", + loss: 0.84, + parity: ParityForLossTarget(4, 0.84, 0.90), + minRecovery: 0.85, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + source := samplePackets(4) + encoded, err := EncodePackets(source, tc.parity) + if err != nil { + t.Fatal(err) + } + rng := rand.New(rand.NewSource(0xC077E)) + const trials = 1000 + recovered := 0 + rawSurvived := 0 + for trial := 0; trial < trials; trial++ { + block := &Block{ + DataShards: encoded.DataShards, + ParityShards: encoded.ParityShards, + ShardSize: encoded.ShardSize, + Shards: make([][]byte, len(encoded.Shards)), + } + rawOK := true + for i, shard := range encoded.Shards { + if rng.Float64() < tc.loss { + if i < encoded.DataShards { + rawOK = false + } + continue + } + block.Shards[i] = append([]byte(nil), shard...) + } + if rawOK { + rawSurvived++ + } + got, decodeErr := DecodePackets(block) + if decodeErr != nil { + continue + } + ok := len(got) == len(source) + for i := range source { + ok = ok && bytes.Equal(got[i], source[i]) + } + if ok { + recovered++ + } + } + recoveryRate := float64(recovered) / trials + rawRate := float64(rawSurvived) / trials + if recoveryRate < tc.minRecovery { + t.Fatalf("recovery %.1f%% below %.1f%% target (loss=%.0f%% parity=%d)", + recoveryRate*100, tc.minRecovery*100, tc.loss*100, tc.parity) + } + if recoveryRate < rawRate+0.50 { + t.Fatalf("FEC improvement too small: recovery=%.1f%% raw=%.1f%%", recoveryRate*100, rawRate*100) + } + }) + } +} + +func TestSuperFECParityMeetsRecoveryTarget(t *testing.T) { + for _, loss := range []float64{0.75, 0.80, 0.84} { + parity := ParityForLossTarget(4, loss, 0.90) + probability := shardRecoveryProbability(4+parity, 4, 1-loss) + if probability < 0.90 { + t.Fatalf("loss=%.0f%% parity=%d recovery=%.3f, want >= 0.90", loss*100, parity, probability) + } + } +} diff --git a/internal/udpserver/server_admission_test.go b/internal/udpserver/server_admission_test.go index 7d2b883..a463f99 100644 --- a/internal/udpserver/server_admission_test.go +++ b/internal/udpserver/server_admission_test.go @@ -126,7 +126,7 @@ func TestIngressAdmissionKeepsDynamicClientCompatibility(t *testing.T) { t.Fatalf("method %d: %v", method, codecErr) } encoded, buildErr := VpnProto.BuildEncoded(VpnProto.BuildOptions{ - SessionID: 0, PacketType: Enums.PACKET_MTU_UP_REQ, + SessionID: 255, PacketType: Enums.PACKET_MTU_UP_REQ, Payload: []byte{0, 1, 2, 3, 4}, }, codec) if buildErr != nil { diff --git a/internal/udpserver/server_ingress.go b/internal/udpserver/server_ingress.go index 08b1410..ace759b 100644 --- a/internal/udpserver/server_ingress.go +++ b/internal/udpserver/server_ingress.go @@ -127,9 +127,9 @@ func (s *Server) matchesInboundPacketCandidate(packet VpnProto.Packet) bool { case Enums.PACKET_SESSION_INIT: return packet.SessionID == 0 && len(packet.Payload) == sessionInitDataSize case Enums.PACKET_MTU_UP_REQ: - return packet.SessionID == 255 && len(packet.Payload) >= mtuProbeUpMinSize + return packet.SessionID == 255 && validMTUUpCandidatePayload(packet.Payload) case Enums.PACKET_MTU_DOWN_REQ: - return packet.SessionID == 255 && len(packet.Payload) >= mtuProbeDownMinSize + return packet.SessionID == 255 && validMTUDownCandidatePayload(packet.Payload) } if packet.SessionID == 0 { @@ -138,3 +138,36 @@ func (s *Server) matchesInboundPacketCandidate(packet VpnProto.Packet) bool { lookup, ok := s.sessions.Lookup(packet.SessionID) return ok && lookup.Cookie == packet.SessionCookie && lookup.LegacySessionID == packet.LegacySessionID } + +func validMTUUpCandidatePayload(payload []byte) bool { + if len(payload) < mtuProbeUpMinSize { + return false + } + if _, ok := parseMTUProbeBaseEncoding(payload[0]); !ok { + return false + } + // Native and legacy clients zero-fill the capacity probe after its response + // mode and four-byte nonce. Checking that padding makes a wrong + // unauthenticated decoder extraordinarily unlikely to steal a valid probe. + return allZero(payload[mtuProbeUpMinSize:]) +} + +func validMTUDownCandidatePayload(payload []byte) bool { + if len(payload) < mtuProbeDownMinSize || !validMTUUpCandidatePayload(payload[:mtuProbeUpMinSize]) { + return false + } + downloadSize := int(payload[mtuProbeUpMinSize])<<8 | int(payload[mtuProbeUpMinSize+1]) + if downloadSize < mtuProbeMinDownSize || downloadSize > mtuProbeMaxDownSize { + return false + } + return allZero(payload[mtuProbeDownMinSize:]) +} + +func allZero(data []byte) bool { + for _, value := range data { + if value != 0 { + return false + } + } + return true +} diff --git a/internal/udpserver/server_tcp.go b/internal/udpserver/server_tcp.go index 59c0d00..1a18394 100644 --- a/internal/udpserver/server_tcp.go +++ b/internal/udpserver/server_tcp.go @@ -26,21 +26,24 @@ import ( ) const ( - tcpReadIdleTimeout = 30 * time.Second - tcpWriteTimeout = 15 * time.Second - tcpMaxMessageLength = 65535 + tcpReadIdleTimeout = 30 * time.Second + tcpWriteTimeout = 15 * time.Second + tcpMaxMessageLength = 65535 + tcpMaxConcurrentQueriesPerConn = 32 ) type tcpServerOptions struct { readIdleTimeout time.Duration writeTimeout time.Duration maxQueriesPerConn int + maxInFlight int } func defaultTCPServerOptions() tcpServerOptions { return tcpServerOptions{ readIdleTimeout: tcpReadIdleTimeout, writeTimeout: tcpWriteTimeout, + maxInFlight: tcpMaxConcurrentQueriesPerConn, } } @@ -159,9 +162,16 @@ func serveTCPDNSMessagesWithOptions(ctx context.Context, conn net.Conn, handler if opts.writeTimeout <= 0 { opts.writeTimeout = tcpWriteTimeout } + if opts.maxInFlight <= 0 { + opts.maxInFlight = tcpMaxConcurrentQueriesPerConn + } lenBuf := make([]byte, 2) queries := 0 + inflight := make(chan struct{}, opts.maxInFlight) + var handlers sync.WaitGroup + var writeMu sync.Mutex + defer handlers.Wait() for { if ctx != nil && ctx.Err() != nil { return @@ -185,24 +195,46 @@ func serveTCPDNSMessagesWithOptions(ctx context.Context, conn net.Conn, handler return } - response := handler(msg) - if len(response) == 0 { - // No tunnel response for this query; keep the connection open for - // the next pipelined message rather than dropping it. - continue - } - if len(response) > tcpMaxMessageLength { - response = response[:tcpMaxMessageLength] + if ctx == nil { + inflight <- struct{}{} + } else { + select { + case inflight <- struct{}{}: + case <-ctx.Done(): + return + } } + handlers.Add(1) + go func(query []byte) { + defer handlers.Done() + defer func() { <-inflight }() + + response := handler(query) + if len(response) == 0 { + // No tunnel response for this query; keep the connection open for + // the next pipelined message rather than dropping it. + return + } + if len(response) > tcpMaxMessageLength { + response = response[:tcpMaxMessageLength] + } - out := make([]byte, 2+len(response)) - binary.BigEndian.PutUint16(out[:2], uint16(len(response))) - copy(out[2:], response) + out := make([]byte, 2+len(response)) + binary.BigEndian.PutUint16(out[:2], uint16(len(response))) + copy(out[2:], response) - _ = conn.SetWriteDeadline(time.Now().Add(opts.writeTimeout)) - if _, err := conn.Write(out); err != nil { - return - } + writeMu.Lock() + defer writeMu.Unlock() + _ = conn.SetWriteDeadline(time.Now().Add(opts.writeTimeout)) + for len(out) > 0 { + n, err := conn.Write(out) + if err != nil || n <= 0 { + _ = conn.Close() + return + } + out = out[n:] + } + }(msg) } } diff --git a/internal/udpserver/server_tcp_test.go b/internal/udpserver/server_tcp_test.go index 4b42aa8..2d3140a 100644 --- a/internal/udpserver/server_tcp_test.go +++ b/internal/udpserver/server_tcp_test.go @@ -211,6 +211,58 @@ func TestServeTCPDNSMessages_MaxQueriesPerConn(t *testing.T) { } } +func TestServeTCPDNSMessagesProcessesPipelineConcurrently(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + + const queryCount = 8 + started := make(chan struct{}, queryCount) + release := make(chan struct{}) + handler := func(q []byte) []byte { + started <- struct{}{} + <-release + return append([]byte(nil), q...) + } + go func() { + serveTCPDNSMessagesWithOptions(context.Background(), server, handler, tcpServerOptions{ + readIdleTimeout: 2 * time.Second, + writeTimeout: 2 * time.Second, + maxInFlight: queryCount, + }) + _ = server.Close() + }() + + for i := 0; i < queryCount; i++ { + if err := writeTCPDNSMessage(client, []byte{byte(i + 1)}); err != nil { + t.Fatalf("write %d: %v", i, err) + } + } + for i := 0; i < queryCount; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatalf("only %d/%d pipelined handlers started concurrently", i, queryCount) + } + } + close(release) + + seen := make(map[byte]bool, queryCount) + for i := 0; i < queryCount; i++ { + _ = client.SetReadDeadline(time.Now().Add(2 * time.Second)) + response, err := readTCPDNSMessage(client) + if err != nil { + t.Fatalf("read %d: %v", i, err) + } + if len(response) != 1 { + t.Fatalf("response %d length=%d want=1", i, len(response)) + } + seen[response[0]] = true + } + if len(seen) != queryCount { + t.Fatalf("received %d/%d distinct pipelined responses", len(seen), queryCount) + } +} + func TestReserveTCPIPSlotHonorsLimitAndRelease(t *testing.T) { activeByIP := map[string]int{} var mu sync.Mutex diff --git a/internal/udpserver/stream_server.go b/internal/udpserver/stream_server.go index 628cdc7..8844fc5 100644 --- a/internal/udpserver/stream_server.go +++ b/internal/udpserver/stream_server.go @@ -610,7 +610,10 @@ func (s *Stream_server) maybeAdjustAutoFEC() { // code rate tracks how bad the link actually is, lifted above the normal // auto ceiling up to the super cap (0 = Reed-Solomon hard limit). This is // loss-aware, not a flat slam: 76% loss buys less parity than 84%. - parity := fec.ParityForLoss(s.fecAutoBlock, loss) + // At extreme loss, sizing parity only to the expected survivor count + // makes reconstruction a coin flip. Super-FEC targets 90% random-loss + // block recovery (subject to the configured/hard parity cap). + parity := fec.ParityForLossTarget(s.fecAutoBlock, loss, 0.90) superCap := s.fecSuperMaxParity hardMax := fec.MaxParity(s.fecAutoBlock) if superCap <= 0 || superCap > hardMax { diff --git a/internal/udpserver/transport_matrix_test.go b/internal/udpserver/transport_matrix_test.go index de79a1d..e2824cc 100644 --- a/internal/udpserver/transport_matrix_test.go +++ b/internal/udpserver/transport_matrix_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "strconv" "testing" "time" @@ -18,10 +19,10 @@ import ( VpnProto "cottendns-go/internal/vpnproto" ) -func newDynamicTransportTestServer(t *testing.T) (*Server, []byte) { +func newDynamicTransportTestServer(t *testing.T, configuredMethod, method int) (*Server, []byte) { t.Helper() const sharedKey = "transport-matrix-shared-key" - preferred, err := security.NewCodec(1, sharedKey) + preferred, err := security.NewCodec(configuredMethod, sharedKey) if err != nil { t.Fatal(err) } @@ -41,14 +42,21 @@ func newDynamicTransportTestServer(t *testing.T) (*Server, []byte) { SupportedUploadCompressionTypes: []int{0, 1, 2, 3}, SupportedDownloadCompressionTypes: []int{0, 1, 2, 3}, }, logger.New("transport-matrix-test", "ERROR"), preferred) - methods := security.AutoDetectMethods(1) + methods := security.AutoDetectMethods(configuredMethod) codecSet, err := security.NewCodecSet(methods, sharedKey) if err != nil { t.Fatal(err) } - s.SetCodecSet(codecSet, 0) + preferredIdx := 0 + for i, candidate := range methods { + if candidate == configuredMethod { + preferredIdx = i + break + } + } + s.SetCodecSet(codecSet, preferredIdx) - changedCodec, err := security.NewCodec(5, sharedKey) + changedCodec, err := security.NewCodec(method, sharedKey) if err != nil { t.Fatal(err) } @@ -73,61 +81,103 @@ func newDynamicTransportTestServer(t *testing.T) (*Server, []byte) { return s, query } -func TestDynamicNativeQueryAcrossStreamTransports(t *testing.T) { - t.Run("TCP", func(t *testing.T) { - s, query := newDynamicTransportTestServer(t) - client, server := net.Pipe() - defer client.Close() - go func() { - serveTCPDNSMessages(context.Background(), server, s.safeHandlePacket) - _ = server.Close() - }() - _ = client.SetDeadline(time.Now().Add(3 * time.Second)) - if err := writeTCPDNSMessage(client, query); err != nil { - t.Fatal(err) - } - if response, err := readTCPDNSMessage(client); err != nil || len(response) == 0 { - t.Fatalf("TCP response: bytes=%d err=%v", len(response), err) - } - }) +func assertDynamicMTUResponse(t *testing.T, response []byte) { + t.Helper() + packet, err := DnsParser.ExtractVPNResponseMatching(response, false, []string{"v.example.com"}) + if err != nil { + t.Fatalf("extract dynamic MTU response: %v", err) + } + if packet.PacketType != Enums.PACKET_MTU_UP_RES { + t.Fatalf("dynamic response packet type = %s, want MTU_UP_RES", Enums.PacketTypeName(packet.PacketType)) + } + if len(packet.Payload) < mtuProbeCodeLength || !bytes.Equal(packet.Payload[:mtuProbeCodeLength], []byte{1, 2, 3, 4}) { + t.Fatalf("dynamic response verification code = %v, want [1 2 3 4]", packet.Payload) + } +} - t.Run("DoT", func(t *testing.T) { - s, query := newDynamicTransportTestServer(t) - cert, err := generateSelfSignedCert([]string{"v.example.com"}) - if err != nil { - t.Fatal(err) - } - clientRaw, serverRaw := net.Pipe() - serverTLS := tls.Server(serverRaw, &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{"dot"}}) - clientTLS := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"dot"}}) // test certificate - defer clientTLS.Close() - go func() { - serveTCPDNSMessages(context.Background(), serverTLS, s.safeHandlePacket) - _ = serverTLS.Close() - }() - _ = clientTLS.SetDeadline(time.Now().Add(3 * time.Second)) - if err := writeTCPDNSMessage(clientTLS, query); err != nil { - t.Fatal(err) - } - if response, err := readTCPDNSMessage(clientTLS); err != nil || len(response) == 0 { - t.Fatalf("DoT response: bytes=%d err=%v", len(response), err) - } - if got := clientTLS.ConnectionState().NegotiatedProtocol; got != "dot" { - t.Fatalf("DoT ALPN = %q, want dot", got) - } - }) +func TestDynamicNativeQueryAcrossAllTransportsAndEncryptionMethods(t *testing.T) { + profiles := []struct { + name string + configured int + methods []int + }{ + {name: "keyed-server", configured: 1, methods: security.AutoDetectMethods(1)}, + {name: "plaintext-enabled-server", configured: 0, methods: security.AutoDetectMethods(0)}, + } + for _, profile := range profiles { + profile := profile + t.Run(profile.name, func(t *testing.T) { + for _, method := range profile.methods { + method := method + t.Run("method-"+strconv.Itoa(method), func(t *testing.T) { + t.Run("UDP", func(t *testing.T) { + s, query := newDynamicTransportTestServer(t, profile.configured, method) + assertDynamicMTUResponse(t, s.safeHandlePacket(query)) + }) - t.Run("DoH", func(t *testing.T) { - s, query := newDynamicTransportTestServer(t) - req := httptest.NewRequest(http.MethodPost, "/dns-query", bytes.NewReader(query)) - req.Header.Set("Content-Type", dohContentType) - response := httptest.NewRecorder() - s.handleDoHRequest(response, req, dohMaxMessageSize) - if response.Code != http.StatusOK || response.Body.Len() == 0 { - t.Fatalf("DoH response: status=%d bytes=%d body=%q", response.Code, response.Body.Len(), response.Body.String()) - } - if got := response.Header().Get("Content-Type"); got != dohContentType { - t.Fatalf("DoH content type = %q", got) - } - }) + t.Run("TCP", func(t *testing.T) { + s, query := newDynamicTransportTestServer(t, profile.configured, method) + client, server := net.Pipe() + defer client.Close() + go func() { + serveTCPDNSMessages(context.Background(), server, s.safeHandlePacket) + _ = server.Close() + }() + _ = client.SetDeadline(time.Now().Add(3 * time.Second)) + if err := writeTCPDNSMessage(client, query); err != nil { + t.Fatal(err) + } + response, err := readTCPDNSMessage(client) + if err != nil { + t.Fatalf("TCP response: %v", err) + } + assertDynamicMTUResponse(t, response) + }) + + t.Run("DoT", func(t *testing.T) { + s, query := newDynamicTransportTestServer(t, profile.configured, method) + cert, err := generateSelfSignedCert([]string{"v.example.com"}) + if err != nil { + t.Fatal(err) + } + clientRaw, serverRaw := net.Pipe() + serverTLS := tls.Server(serverRaw, &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{"dot"}}) + clientTLS := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"dot"}}) // test certificate + defer clientTLS.Close() + go func() { + serveTCPDNSMessages(context.Background(), serverTLS, s.safeHandlePacket) + _ = serverTLS.Close() + }() + _ = clientTLS.SetDeadline(time.Now().Add(3 * time.Second)) + if err := writeTCPDNSMessage(clientTLS, query); err != nil { + t.Fatal(err) + } + response, err := readTCPDNSMessage(clientTLS) + if err != nil { + t.Fatalf("DoT response: %v", err) + } + assertDynamicMTUResponse(t, response) + if got := clientTLS.ConnectionState().NegotiatedProtocol; got != "dot" { + t.Fatalf("DoT ALPN = %q, want dot", got) + } + }) + + t.Run("DoH", func(t *testing.T) { + s, query := newDynamicTransportTestServer(t, profile.configured, method) + req := httptest.NewRequest(http.MethodPost, "/dns-query", bytes.NewReader(query)) + req.Header.Set("Content-Type", dohContentType) + response := httptest.NewRecorder() + s.handleDoHRequest(response, req, dohMaxMessageSize) + if response.Code != http.StatusOK || response.Body.Len() == 0 { + t.Fatalf("DoH response: status=%d bytes=%d body=%q", response.Code, response.Body.Len(), response.Body.String()) + } + if got := response.Header().Get("Content-Type"); got != dohContentType { + t.Fatalf("DoH content type = %q", got) + } + assertDynamicMTUResponse(t, response.Body.Bytes()) + }) + }) + } + }) + } } diff --git a/internal/vpnproto/autodetect_test.go b/internal/vpnproto/autodetect_test.go index d9c4a33..d776acf 100644 --- a/internal/vpnproto/autodetect_test.go +++ b/internal/vpnproto/autodetect_test.go @@ -163,6 +163,36 @@ func TestParseInflatedFromLabelsAnyPreferredFirstStillWorks(t *testing.T) { } } +func TestCodecTrialOrderNeverPlacesLegacyBeforeAEAD(t *testing.T) { + set, err := security.NewCodecSet(security.AllMethods, autoDetectKey) + if err != nil { + t.Fatalf("NewCodecSet: %v", err) + } + + for _, start := range []int{0, 1, 2, 3, 4, 5} { + authenticatedDone := false + authenticatedCount := 0 + for trial := 0; trial < len(set); trial++ { + idx := codecTrialIndex(set, start, trial) + if idx < 0 { + t.Fatalf("start %d trial %d returned no codec", start, trial) + } + authenticated := security.IsAuthenticatedMethod(set[idx].Method()) + if !authenticated { + authenticatedDone = true + continue + } + if authenticatedDone { + t.Fatalf("start %d put authenticated method %d after a legacy decoder", start, set[idx].Method()) + } + authenticatedCount++ + } + if authenticatedCount != 3 { + t.Fatalf("start %d tried %d authenticated methods, want 3", start, authenticatedCount) + } + } +} + func TestParseInflatedFromLabelsAnyEmptySet(t *testing.T) { if _, _, err := ParseInflatedFromLabelsAny("abc", nil, 0); err == nil { t.Fatal("expected error for empty codec set") diff --git a/internal/vpnproto/payload.go b/internal/vpnproto/payload.go index d7ac3e8..3de5790 100644 --- a/internal/vpnproto/payload.go +++ b/internal/vpnproto/payload.go @@ -112,12 +112,12 @@ func parseFromLabelsAnyMatching(labels string, codecs []*security.Codec, startId fallbackIdx = -1 lastErr error ) - for offset := 0; offset < n; offset++ { - idx := (startIdx + offset) % n - codec := codecs[idx] - if codec == nil { - continue + for trial := 0; trial < n; trial++ { + idx := codecTrialIndex(codecs, startIdx, trial) + if idx < 0 { + break } + codec := codecs[idx] raw, err := codec.DecodeStringAndDecrypt(labels) if err != nil { lastErr = err @@ -154,9 +154,22 @@ func parseFromLabelsAnyMatching(labels string, codecs []*security.Codec, startId switch { case nativeOK && !legacyOK: - return native, idx, nil + // AEAD already proves the decoder. Legacy ciphers do not, so use + // server session/pre-session semantics before allowing one of them + // to claim ciphertext that may belong to another legacy method. + if security.IsAuthenticatedMethod(codec.Method()) || match == nil || match(native) { + return native, idx, nil + } + if fallbackIdx < 0 { + fallback, fallbackIdx = native, idx + } case legacyOK && !nativeOK: - return legacy, idx, nil + if security.IsAuthenticatedMethod(codec.Method()) || match == nil || match(legacy) { + return legacy, idx, nil + } + if fallbackIdx < 0 { + fallback, fallbackIdx = legacy, idx + } case nativeOK && legacyOK: if match != nil { if match(native) { @@ -193,10 +206,10 @@ func ParseFromLabelsAny(labels string, codecs []*security.Codec, startIdx int) ( } var lastErr error - for offset := 0; offset < n; offset++ { - idx := (startIdx + offset) % n - if codecs[idx] == nil { - continue + for trial := 0; trial < n; trial++ { + idx := codecTrialIndex(codecs, startIdx, trial) + if idx < 0 { + break } packet, err := ParseFromLabels(labels, codecs[idx]) if err == nil { @@ -210,6 +223,43 @@ func ParseFromLabelsAny(labels string, codecs []*security.Codec, startIdx int) ( return Packet{}, -1, lastErr } +// codecTrialIndex preserves the preferred codec fast path within each security +// class while always exhausting authenticated codecs before attempting an +// unauthenticated decoder. This matters because XOR/ChaCha20/None cannot prove +// that decrypted bytes came from their method: random ciphertext can +// occasionally resemble a structurally valid frame. AES-GCM candidates do +// authenticate, so they must never be placed behind those ambiguous decoders. +// +// The codec set is tiny (at most the six supported methods), making this +// allocation-free two-phase walk cheaper than constructing a reordered slice +// for every DNS packet. +func codecTrialIndex(codecs []*security.Codec, startIdx, trial int) int { + n := len(codecs) + if n == 0 || trial < 0 { + return -1 + } + if startIdx < 0 || startIdx >= n { + startIdx = 0 + } + + seen := 0 + for phase := 0; phase < 2; phase++ { + wantAuthenticated := phase == 0 + for offset := 0; offset < n; offset++ { + idx := (startIdx + offset) % n + codec := codecs[idx] + if codec == nil || security.IsAuthenticatedMethod(codec.Method()) != wantAuthenticated { + continue + } + if seen == trial { + return idx + } + seen++ + } + } + return -1 +} + func ParseInflated(data []byte) (Packet, error) { packet, err := Parse(data) if err != nil { diff --git a/scripts/bench/README.md b/scripts/bench/README.md index d536856..b31967b 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -27,6 +27,7 @@ go run scripts/bench/bench.go -runs 3 -bytes 10485760 | `-force-build` | Rebuild server and client binaries | true | | `-client-port` | Port for the local client listener | 18080 | | `-server-port` | Port for the UDP server listener | 5300 | +| `-path-controller` | Compare `unified` or rollback `legacy` client behavior | unified | --- diff --git a/scripts/bench/bench.go b/scripts/bench/bench.go index e94e280..6a21dde 100644 --- a/scripts/bench/bench.go +++ b/scripts/bench/bench.go @@ -19,12 +19,13 @@ import ( ) var ( - runs = flag.Int("runs", 3, "Number of runs for each direction") - payloadMiB = flag.Int("bytes", 100*1024*1024, "Payload size in bytes (default 100MiB)") - forceBuild = flag.Bool("force-build", true, "Force rebuilding binaries") - benchPort = flag.Int("bench-port", 19090, "Legacy port (not used much now with dynamic targets)") - clientPort = flag.Int("client-port", 18080, "Port for the CottenDns client listener") - serverPort = flag.Int("server-port", 5300, "Port for the CottenDns server UDP listener") + runs = flag.Int("runs", 3, "Number of runs for each direction") + payloadMiB = flag.Int("bytes", 100*1024*1024, "Payload size in bytes (default 100MiB)") + forceBuild = flag.Bool("force-build", true, "Force rebuilding binaries") + benchPort = flag.Int("bench-port", 19090, "Legacy port (not used much now with dynamic targets)") + clientPort = flag.Int("client-port", 18080, "Port for the CottenDns client listener") + serverPort = flag.Int("server-port", 5300, "Port for the CottenDns server UDP listener") + pathController = flag.String("path-controller", "unified", "Client path controller: unified or legacy") // Standalone / slipstream-like flags optMode = flag.String("mode", "", "Standalone mode: 'sink', 'source', 'send', 'recv'") @@ -71,6 +72,9 @@ func nowAsTs() float64 { func main() { flag.Parse() + if *pathController != "unified" && *pathController != "legacy" { + log.Fatalf("Invalid -path-controller %q (want unified or legacy)", *pathController) + } runtime.GOMAXPROCS(runtime.NumCPU()) if *optMode != "" { @@ -80,7 +84,8 @@ func main() { fmt.Printf("🚀 Starting CottenDns Go-Benchmark (slipstream-style timing)\n") fmt.Printf("📂 Working Dir: %s\n", benchDir) - fmt.Printf("💾 Payload: %.2f MiB | Runs: %d\n\n", float64(*payloadMiB)/(1024*1024), *runs) + fmt.Printf("💾 Payload: %.2f MiB | Runs: %d | Controller: %s\n\n", + float64(*payloadMiB)/(1024*1024), *runs, *pathController) if err := setupDirs(); err != nil { log.Fatalf("Failed to setup directories: %v", err) @@ -261,6 +266,8 @@ func runOnce(ctx context.Context, direction string, runIndex int) (BenchResult, DOMAINS = ["a.io"] ENCRYPTION_KEY = "%s" RESOLVER_BALANCING_STRATEGY = 1 + PATH_CONTROLLER_MODE = "%s" + COMPARABLE_PATH_STRIPING = true DATA_ENCRYPTION_METHOD = 3 UPLOAD_PACKET_DUPLICATION_COUNT = 1 DOWNLOAD_PACKET_DUPLICATION_COUNT = 1 @@ -314,7 +321,7 @@ func runOnce(ctx context.Context, direction string, runIndex int) (BenchResult, ARQ_MAX_CONTROL_RETRIES = 300 ARQ_DATA_NACK_INITIAL_DELAY_SECONDS = 0.35 ARQ_DATA_NACK_REPEAT_SECONDS = 0.8 - `, *clientPort, encryptionKey)), 0644) + `, *clientPort, encryptionKey, *pathController)), 0644) absClientBin, _ := filepath.Abs(filepath.Join(binDir, "client.exe")) clientCmd := exec.Command(absClientBin, "--config", clientCfg)