MTProxy и WireGuard на одном 443 порту: SNI-routing через nginx stream

Зачем это вообще нужно

У меня на VPS крутится WireGuard для своих задач — обычный setup, UDP, нестандартный порт. Работает, пока сеть на стороне клиента не делает что-то странное: блокирует UDP полностью, режет всё кроме TLS, или просто отдаёт нестабильную маршрутизацию для всего, что не похоже на HTTPS. У многих провайдеров и в корпоративных сетях такая политика — пропускают TCP/443, остальное по остаточному принципу или вообще никак.

Параллельно для Telegram хотелось поднять MTProxy — он лёгкий, делает faketls-маскировку под HTTPS-трафик и закрывает кейс "WireGuard не идёт, а написать в чат всё равно надо". Но MTProxy тоже хочет TCP/443, чтобы маскировка работала корректно. Двух 443-х на одном IP не бывает.

Решение, которое в итоге собрал — nginx в режиме stream-модуля как L4-роутер по SNI. Один слушает 443, смотрит на ClientHello из TLS-handshake, и по значению SNI разводит соединение либо в MTProxy-контейнер, либо на бэкенд WireGuard-веба (панель/сайт). MTProxy получает TCP-соединение которое для DPI выглядит как обычный TLS к "azure.microsoft.com", а реальный сайт сервиса — отдельной поддоменной зоной.

WireGuard при этом остаётся на своём UDP-порту — voice/video через MTProxy не пойдут, и это нормально. Цель здесь не заменить WG, а добавить fallback под текстовые мессенджеры когда основной канал лежит.

Архитектура
Код:
        Клиент (TLS ClientHello)
                 │
                 ▼
          [nginx :443 stream]
          smart SNI-routing
            │            │
   SNI = маскировка     SNI = панель
            │            │
            ▼            ▼
       [MTProxy:444]  [nginx http:8443]
                              │
                              ▼
                          WG admin/site
       UDP отдельно: WireGuard на :51820
Идея простая: nginx stream не терминирует TLS, он только читает первый пакет, видит расширение server_name и форвардит соединение целиком на нужный апстрим. Шифрование остаётся между клиентом и тем, кто реально терминирует — MTProxy в одном случае, http-блок nginx в другом.

Шаг 1. MTProxy в Docker

Беру mtg v2 — это активно поддерживаемая Go-реализация MTProxy от nineseimin, в отличие от оригинального C-кода она нормально работает в контейнере и умеет faketls "из коробки".
docker-compose.yml:
YAML:
services:
  mtg:
    image: nineseimin/mtg:2
    container_name: mtg
    restart: unless-stopped
    ports:
      - "127.0.0.1:444:3128"
    volumes:
      - ./mtg-config.toml:/config.toml:ro
    command: ["run", "/config.toml"]
Слушаем строго на 127.0.0.1:444 — снаружи этот порт никому не виден, ходить туда будет только nginx с того же хоста.
mtg-config.toml:
Код:
secret = "ee" + "<32 hex symbols>" + hex("azure.microsoft.com")
bind-to = "0.0.0.0:3128"
[network]
prefer-ip = "prefer-ipv4"
[defense.anti-replay]
enabled = true
max-size = "1mb"
[defense.allow-tls-bypass]
enabled = false
Секрет генерируется один раз: префикс ee означает faketls, потом 32 hex-символа собственно ключа, потом hex от домена-маски. Я взял azure.microsoft.com — он живой, отдаёт реальный TLS, и у крупных провайдеров не вызывает подозрений (cloudflare-домены палятся быстрее, потому что слишком многие proxy используют их по умолчанию).
Сгенерировать ключевую часть можно так:
Bash:
docker run --rm nineseimin/mtg:2 generate-secret azure.microsoft.com
Поднимаем:
Bash:
docker compose up -d mtg
docker logs mtg --tail 20
В логах должно быть Server started, без warnings про неправильный faketls-домен.

Шаг 2. nginx со stream-модулем и SNI-routing

На Debian/Ubuntu стандартный пакет nginx идёт без stream-модуля по умолчанию — нужен nginx-extras или сборка с --with-stream --with-stream_ssl_preread_module. Проверить:
Bash:
nginx -V 2>&1 | tr ' ' '\n' | grep stream
Если --with-stream_ssl_preread_module есть — годно. Если нет — apt install nginx-extras.
/etc/nginx/nginx.conf, добавляем блок stream на верхнем уровне, не внутри http:
NGINX:
stream {
    log_format basic '$remote_addr [$time_local] '
                     '$protocol $status $bytes_sent $bytes_received '
                     '$session_time "$ssl_preread_server_name"';
    access_log /var/log/nginx/stream.log basic;
    map $ssl_preread_server_name $upstream {
        azure.microsoft.com    mtproxy_backend;
        www.azure.microsoft.com mtproxy_backend;
        panel.example.com      web_backend;
        default                web_backend;
    }
    upstream mtproxy_backend {
        server 127.0.0.1:444;
    }
    upstream web_backend {
        server 127.0.0.1:8443;
    }
    server {
        listen 443;
        listen [::]:443;
        ssl_preread on;
        proxy_pass $upstream;
        proxy_connect_timeout 5s;
        proxy_timeout 10m;
    }
}
Что тут происходит:
  • ssl_preread on — nginx читает первые байты соединения, парсит TLS ClientHello и достаёт SNI, не терминируя соединение. Это критично — если попытаться терминировать, MTProxy faketls сломается, потому что внутри идёт не настоящий TLS-handshake, а его имитация.
  • map смотрит на $ssl_preread_server_name и выбирает апстрим. Клиенты MTProxy всегда отправляют SNI = домену из секрета, поэтому всё с azure.microsoft.com идёт в mtg.
  • Всё остальное (твой реальный домен панели, IP-сканеры, любые левые запросы) — на http-бэкенд, где обычный nginx с ssl_certificate от Let's Encrypt отдаёт сайт.
HTTP-блок переезжает на 127.0.0.1:8443:
NGINX:
server {
    listen 127.0.0.1:8443 ssl http2;
    server_name panel.example.com;
    ssl_certificate     /etc/letsencrypt/live/panel.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
    # ... обычный конфиг панели
}
Перезапускаем:
Bash:
nginx -t
systemctl reload nginx
Шаг 3. Проверка

С внешней машины:
Bash:
# проверяем что snI на маскировочный домен идёт в mtproxy
openssl s_client -connect <server-ip>:443 -servername azure.microsoft.com </dev/null 2>&1 | head -20
# проверяем что обычный домен отдаёт реальный сертификат
openssl s_client -connect <server-ip>:443 -servername panel.example.com </dev/null 2>&1 | grep "subject="
В первом случае соединение установится, но дальше TLS-handshake не пройдёт — это норма, потому что для MTProxy нужен правильный faketls-клиент с секретом, а openssl s_client им не является. В логах stream.log должна появиться строка с ssl_preread_server_name=azure.microsoft.com, ушедшая в mtproxy_backend.
Во втором случае получишь нормальный сертификат от Let's Encrypt — значит non-MTProxy трафик корректно роутится в http-бэкенд.
Из Telegram добавляем прокси по схеме https://t.me/proxy?server=<host>&port=443&secret=<full_secret> — должен подключиться и в настройках показать "MTPROTO".

Гочи, на которые я наступил

1. AnyConnect/Cloudflare-домены не работают как маскировка.
Брал сначала cloudflare.com — клиенты Telegram с обновлением с какого-то момента стали выдавать ошибку handshake. Подозреваю что MTProxy либо клиент стал валидировать что домен реально отдаёт TLS-сертификат с правильным CN. azure.microsoft.com, itunes.apple.com, aws.amazon.com — работают стабильно.
2. UDP остаётся снаружи. WireGuard на 51820/udp по-прежнему доступен напрямую и блокируется так же, как блокировался. Эта схема — про fallback для текста, а не про универсальное решение. Я в подписке отдаю клиенту и WG-конфиг, и MTProxy-ссылку, в инструкции пишу: "если WG не подключается — пробуй прокси для Telegram".
3. Логи nginx выдают ваш SNI-маскировочный домен в plain text. Не критично, но если хост попадёт в чужие руки или сольют access.log — анализ покажет паттерн использования. Я отключил access_log для stream-блока в проде, оставил только error_log.
4. Anti-replay в mtg ест RAM. На VPS с 1ГБ держать max-size = "10mb" — упрётся в OOM при пиковой нагрузке. 1mb хватает для личного использования / небольшой группы клиентов.

Что в итоге

Один TCP/443 порт обслуживает и MTProxy с маскировкой под Azure, и обычный HTTPS панели управления — за счёт того что nginx работает как L4-роутер по SNI, не лезет внутрь шифрованного трафика, и просто пробрасывает соединение целиком. WireGuard живёт отдельно на UDP, и у клиента в итоге две независимых дорожки.
---
Если кому-то лень собирать руками — у меня этот сетап (WG + MTProxy на одном 443 через SNI) собран как сервис на своих серверах, домен - **Fiery VPN. Бесплатный пробный период, оплата крипто, никаких логов. Не реклама в смысле "приходите все" — просто если кто-то спросит "а где такое уже работающее посмотреть", вот оно.
Замечания по конфигам, тонким местам или альтернативным маскировочным доменам — пишите в комментариях, обновлю статью.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab