Разворачивает VPN-сервер на 3x-ui + VLESS/Reality одной командой: от чистой Ubuntu до работающего inbound с BBR, файрволом и закреплённым адресом в ссылках. В конце печатает JSON-паспорт сервера для интеграции с биллингом.
Скрипт собран из практики разворачивания флота серверов — все обходные пути в нём появились после реальных сбоев, а не «на всякий случай».
Главный принцип: узел не оставляет публичных следов. Наружу открыт ровно один порт, сертификат на имя узла не выпускается, панель доступна только через SSH-туннель. Почему именно так — в разделе След в публичных логах; это не паранойя, а разбор того, как находят и блокируют узлы.
- 3x-ui (MHSanaei) с панелью на случайном порту и секретном base path, слушающей только 127.0.0.1
- VLESS + Reality на 443/tcp, flow
xtls-rprx-vision, маскировка подgithub.com,minClientVer 1.0.0 - Адрес в ссылках закреплён за доменом через
externalProxy— смена IP сервера больше не убивает клиентов - BBR + fq и тюнинг TCP-буферов
- ufw: наружу открыты только 22 и 443. Порт панели — либо закрыт совсем, либо открыт списку адресов ваших бэкендов
- Подписка на узле выключена: её собирают бэкенды, узлу лишний открытый порт с сертификатом не нужен
- fail2ban с джейлом 3x-ui (IP Limit)
- torrent-block — DPI-фильтр против BitTorrent-раздач, снимает DMCA-жалобы
- JSON-паспорт со всеми параметрами подключения
Сертификата на узле по умолчанию нет — он там не нужен и вреден. TrafficGuard по умолчанию не ставится, включается флагом.
Три канала, по которым находят узлы. Первый — основной и почти всегда недооценённый.
1. Certificate Transparency. Выдача любого сертификата Let's Encrypt обязательно публикуется в открытые неудаляемые логи. Это не утечка и не изъян конкретного CA, так устроена вся экосистема. Следствие: домен узла становится публично известен через минуты после выпуска, а весь флот выгружается одним запросом без авторизации:
curl -s "https://api.certspotter.com/v1/issuances?domain=example.com\
&include_subdomains=true&expand=dns_names" | python3 -m json.toolНа реальной зоне это вернуло 34 имени узлов с датами появления. Наблюдателю достаточно подписаться на зону и получать уведомления о каждом новом сервере раньше, чем вы отдадите ссылку первому абоненту.
A-записи в CT не попадают — публикуются только сертификаты. Поэтому домен
для узла заводить нужно, а сертификат на него — нет. В Reality клиент
предъявляет SNI цели маскировки (github.com), доменное имя узла в TLS не
участвует вообще, и сертификат ему не нужен ни для чего.
2. Узел, называющий себя сам. Если панель или подписка висят на открытом порту с сертификатом, они отдают его любому, кто подключится по голому IP вообще без SNI. Слепое сканирование диапазонов хостера тогда даёт не «здесь что-то есть», а «этот IP принадлежит такой-то зоне» — и связывает все ваши узлы в одну сеть.
Это отдельный канал от CT-логов, и wildcard от него не спасает: сертификат
отдаётся живым соединением, и CN=*.example.com в нём читается так же хорошо.
Конкретное имя узла останется скрытым, зона — нет. Закрывается только тем, что
порт вообще не отвечает: --panel-allow или петля. Проверить свой сервер:
openssl s_client -connect <IP>:<порт-панели> </dev/null 2>&1 | grep subject=
# должно быть пусто: панель не должна отвечать снаружи вообще3. Ссылки из публичных ботов. Раздача через открытого Telegram-бота означает, что подписаться и собрать адреса может кто угодно. Это не лечится настройками сервера — лечится тем, что абонент держит адрес фронта, а не узла.
Отсюда правила, которым следует скрипт:
| Правило | Как реализовано |
|---|---|
| На узле нет сертификата | выпуск только по явному --issue-cert |
| Панель не отвечает сканеру | петля по умолчанию либо --panel-allow со списком |
| Нет лишних слушателей | subEnable=false, порт 2096 не открывается |
| Наружу только 443 | ufw + проверка лишних портов в конце установки |
| Адрес в ссылке переставляем | externalProxy закреплён за доменом |
Что остаётся за рамками скрипта, но обязательно к исполнению:
- имена узлов не последовательные.
de01,de02,tr01добиваются перебором по словарю за минуты, даже если в CT их нет. Случайная метка видаk7m2q9не добивается - отдельная зона под узлы. Домен, засвеченный в CT фронтом и подпиской, не должен использоваться для адресации узлов
- подписка не на узле. Отдавайте её с общего фронта за Cloudflare, чтобы трафик абонентов не касался адреса узла
- цель маскировки под геолокацию.
github.comзаезжен, Xray сам об этом предупреждает в логе, а «github» в турецком диапазоне мелкого хостера — аномалия сама по себе. Задавайте--destосознанно
- Ubuntu 22.04 или 24.04, root-доступ
- Домен с A-записью, указывающей на сервер. Сертификат на него не выпускается, но проверка A-записи всё равно делается — по ней клиенты находят узел
- Ядро 4.9+ для BBR — у Ubuntu 22.04/24.04 это выполняется по умолчанию
Сервер должен быть чистым. Если на нём уже что-то крутится на 443, разбирайся с этим до запуска.
git clone https://github.com/<USER>/3xui-deploy.git
cd 3xui-deploy
chmod +x deploy.sh server-info.sh
./deploy.sh \
--domain k7m2q9.example.com \
--name "Германия 1" \
--code DE \
--country "Германия" \
--dest asus.comЗанимает три-пять минут. Всё, что нельзя вывести из системы — отображаемое имя, код страны, описание — передаётся флагами и попадает в паспорт.
| Флаг | Назначение |
|---|---|
--domain FQDN |
обязательный. Домен узла; сертификат на него не выпускается |
--name TEXT |
отображаемое имя, напр. "Германия 1" |
--code ISO2 |
код страны, напр. DE — от него зависит флаг в интерфейсе |
--country TEXT |
страна по-русски |
--description TEXT |
описание, по умолчанию VLESS Reality |
--info TEXT |
доп. поле, опционально |
--dest HOST |
цель маскировки Reality, по умолчанию github.com |
--remark TEXT |
имя inbound'а, по умолчанию <CODE>-Reality-443 |
--cert-file PATH |
поставить готовый сертификат (напр. общий wildcard) |
--key-file PATH |
ключ к нему, только вместе с --cert-file |
--issue-cert |
выпустить LE на имя узла — попадёт в CT-логи |
--acme-email MAIL |
e-mail аккаунта Let's Encrypt, только с --issue-cert |
--panel-allow IP[,IP] |
открыть порт панели только этим адресам; можно повторять |
--panel-public |
открыть порт панели всему миру |
--sub-on-node |
оставить подписку на узле (по умолчанию выключена) |
--trafficguard |
поставить TrafficGuard (по умолчанию не ставится) |
--skip-torrent-block |
не ставить блокировку торрентов |
--harden-ssh |
отключить вход по паролю — только если найден SSH-ключ |
--skip-dns-check |
не проверять A-запись |
Три режима. Порт и base path печатаются в конце установки и лежат в
/etc/x-ui/install-result.env.
По умолчанию — петля. Панель слушает 127.0.0.1, снаружи её нет вообще.
С рабочей машины:
ssh -N -L 32566:127.0.0.1:32566 root@<IP>
# затем открыть http://127.0.0.1:32566/<base-path>/--panel-allow — список адресов. Нужен, когда к панели по API ходят ваши
бэкенды: они собирают клиентов со всех узлов и сами формируют подписку.
./deploy.sh --domain k7m2q9.example.com \
--panel-allow 203.0.113.11,203.0.113.12,198.51.100.7 \
--cert-file /root/cert/wild/fullchain.pem \
--key-file /root/cert/wild/privkey.pemПанель слушает все интерфейсы, но ufw пускает только перечисленные адреса — сканеру порт не отвечает, и снять с него отпечаток с вашим доменом нельзя. Флаг можно повторять или перечислять адреса через запятую; принимаются и подсети в форме CIDR.
Сертификат в этом режиме нужен. Бэкенды ходят к панели через интернет, и без
TLS логин с API-токеном полетят открытым текстом. Ставьте общий wildcard. Без
--cert-file скрипт об этом предупредит, но установку не прервёт.
Важно не перепутать, что чем закрывается — это две разные задачи:
| Утечка | Чем закрывается |
|---|---|
| имя узла в публичных CT-логах | wildcard вместо сертификата на имя узла |
| сканер снимает сертификат с живого порта | --panel-allow: порт не отвечает |
Wildcard решает только первую. Если порт панели отвечает кому угодно, сканер
всё равно заберёт сертификат живым соединением и увидит в нём CN=*.example.com
— конкретный узел не раскроется, но зона станет известна, а вместе с ней и факт
принадлежности этого IP вашей сети. Полностью имя прячет только закрытый порт.
Нужны оба средства, по разным причинам.
--panel-public — наружу для всех. Скрипт предупреждает и делает: открытая
панель отдаёт сканеру отпечаток и связывает IP с вашей сетью, даже если порт
нестандартный, а путь секретный.
По умолчанию выключена: subEnable=false, subListen=127.0.0.1, порт 2096
в ufw не открывается. Штатная схема — бэкенды забирают клиентов с узлов по API
панели и формируют подписку сами, сразу со всеми серверами. Узлу при этом
раздавать конфиги незачем.
В 3x-ui подписка включена по умолчанию (subEnable=true, порт 2096, путь
/sub/, слушает все интерфейсы), поэтому скрипт выключает её явно. Если она
всё же нужна на узле — флаг --sub-on-node, но порт придётся открыть руками.
Это гигиена, а не закрытие дыры: снаружи 2096 и так недоступен, пока в ufw нет
правила. Но слушатель на 0.0.0.0, который никому не нужен, отделён от публики
одной строчкой конфига — а в скрипте есть ufw --force reset, и любая ошибка в
правилах открывает порт мгновенно. Лишнее лучше не держать включённым вообще.
Ключ subURI задаёт префикс, который панель подставляет в выдаваемый URL
подписки. Если раздачей занимается фронт, пропишите там его адрес.
Узлу — никогда. Фронту с подпиской — да, там HTTPS обязателен.
Если сертификат на узле всё же нужен (например, панель должна быть доступна снаружи по HTTPS), не выпускайте его на имя узла. Выпустите один wildcard на зону через DNS-01 и разложите файлы по серверам:
# один раз, на любой машине
export CF_Token="cloudflare-токен-с-правом-Zone:DNS:Edit"
acme.sh --issue --dns dns_cf -d '*.example.com' -d 'example.com'
acme.sh --install-cert -d '*.example.com' \
--key-file /root/cert/wild/privkey.pem \
--fullchain-file /root/cert/wild/fullchain.pem
# на узле
./deploy.sh --domain k7m2q9.example.com \
--cert-file /root/cert/wild/fullchain.pem \
--key-file /root/cert/wild/privkey.pemВ CT попадает одна строка *.example.com, и новые узлы больше не добавляют
туда ничего. Минус: обновлять раз в ~60 дней и раскладывать обновлённый файл
по всем узлам — автоматизируйте через --renew-hook.
Ещё раз про границы: wildcard закрывает CT-логи, но не закрывает снятие
сертификата с живого порта. Пока порт панели отвечает всем, сканер получит с
него CN=*.example.com и опознает зону. Держите порт под --panel-allow.
--issue-cert оставлен для случаев, когда это осознанное решение. Скрипт
предупредит о последствиях, но выполнит.
Выводится в конце установки. Повторно — в любой момент:
NAME="Германия 1" CODE=DE COUNTRY="Германия" DOMAIN=k7m2q9.example.com ./server-info.shПечатает в stdout, ничего не сохраняет.
{
"panel_type": "3x-ui-v3",
"ip": "k7m2q9.example.com",
"ipv4": "203.0.113.10",
"domain": "k7m2q9.example.com",
"port_panel": 32566,
"uri_path": "/XXXXXXXXXXXXXXXXXX/",
"panel_login": "REDACTED",
"panel_password": "REDACTED",
"panel_api_token": "REDACTED",
"panel_version": "3.6.0",
"xray_version": "26.7.28",
"vless_inbound_id": 1,
"port_key": 443,
"security": "reality",
"network": "tcp",
"flow": "xtls-rprx-vision",
"sni": "github.com",
"public_key": "REDACTED",
"short_id": "REDACTED",
"fingerprint": "firefox",
"cert_expires": null,
"name": "Германия 1",
"code": "DE",
"country": "Германия",
"description": "VLESS Reality",
"info": null
}Поле ip — публичный домен, а не адрес: именно его видят клиенты, и именно
он нужен, если сервер стоит за CDN. Фактический адрес лежит отдельно в ipv4.
Переопределяется через IP_PUBLIC=front.example.com ./server-info.sh.
Имя узла берётся из переменной DOMAIN, а при её отсутствии — из CN
сертификата и затем из hostname. Раз сертификата на узле штатно нет, передавай
DOMAIN явно, иначе в паспорт уедет hostname.
cert_expires равен null, когда сертификата нет. Это норма, а не ошибка.
Вывод содержит пароль панели и API-токен открытым текстом. Так задумано — паспорт идёт в биллинг, — но помни, что он попадает в историю терминала.
# Reality отдаёт настоящий сертификат цели маскировки — запускать с ДРУГОЙ машины
openssl s_client -connect <IP>:443 -servername github.com </dev/null 2>/dev/null \
| grep -E 'subject=|Verify return code'
# ожидаем: subject=CN = github.com, Verify return code: 0 (ok)
# наружу не должно быть открыто ничего кроме 22 и 443
# (порт панели, ограниченный списком бэкендов, лишним не считается)
ufw status | grep -E 'ALLOW[[:space:]]+Anywhere'
# подписка выключена
sqlite3 /etc/x-ui/x-ui.db "select key,value from settings where key like 'sub%';"
# адрес в ссылке закреплён за доменом, а не зеркалит Host
sqlite3 /etc/x-ui/x-ui.db \
"select json_extract(stream_settings,'$.externalProxy') from inbounds where port=443;"
sysctl -n net.ipv4.tcp_congestion_control # bbr
systemctl is-active x-ui fail2banЕсли openssl вернул свой сертификат или оборвал соединение — маскировка не
работает. На macOS системный LibreSSL этого вывода не показывает, гоняй с Linux.
Отдельно проверь, что узла нет в CT-логах: запрос из раздела След в публичных логах не должен возвращать его имя.
deploy.sh основной скрипт, запускать на сервере от root
server-info.sh JSON-паспорт
lib/inbound.py создание inbound'а VLESS+Reality в базе 3x-ui
docs/os-reinstall.md переустановка ОС по сети, если приехал не Ubuntu
docs/troubleshooting.md грабли и как их обходить
Бывает, что вместо Ubuntu разворачивается CentOS 7 или переустановка из панели молча не срабатывает. Лечится сетевой переустановкой прямо с живой системы — docs/os-reinstall.md.
Собраны в docs/troubleshooting.md. Коротко:
- адрес в ссылке — зеркало заголовка Host того запроса, которым её
сгенерировали. Дёрнули API по IP — в ссылке окажется IP, и клиент прибит к
нему намертво: сменили адрес сервера, и абонент мёртв, причём обновиться сам
не может, если URL подписки тоже был на IP. Лечится
externalProxy, который скрипт теперь проставляет сам unattended-upgradesдержит dpkg на свежей машине — скрипт ждёт освобождения- у
settings.keyв базе 3x-ui нет уникального индекса,ON CONFLICTне работает - панель держит SQLite открытой — перед правкой настроек её надо гасить
- мёртвый хост может отвечать SYN-ACK через оборудование провайдера —
nc -zврёт - джейл
3x-iplот установщика 3x-ui идёт сmaxretry=1иfindtime=32и банит реальные адреса абонентов за превышение лимита устройств. Если жалуются на внезапные отвалы — смотреть туда
Ставится только по флагу --trafficguard. Раньше ставился по умолчанию —
поведение изменено осознанно.
Он банит по ipset тех, кто стучится в закрытые порты, собирая их из строк
UFW BLOCK. Против описанного выше сценария это бесполезно: обнаружение узла
идёт из CT-логов и вообще не требует сканирования, а там где сканирование
есть — оно идёт по вашим открытым портам, которых TrafficGuard по своей
природе не касается. На боевом узле он за полтора часа аптайма отбросил ноль
пакетов, держа в памяти 2933 записи.
Цена при этом ненулевая: сотни правил в ufw и риск потерять SSH, если ваша сеть попадёт в блок-листы. Включайте, если понимаете, зачем именно.
Ставится по умолчанию, отключается флагом --skip-torrent-block. Это
3xui-torrent-block — DPI-фильтр на
iptables, который режет обнаружение пиров: DHT, HTTP/HTTPS-трекеры по сигнатурам
и SNI, классические BitTorrent-порты, незашифрованный peer-wire handshake.
Он работает в дополнение к правилу Xray bittorrent → blocked, а не вместо
него: правило Xray отсекает торрент внутри туннеля, фильтр — исходящий поиск
пиров с самого сервера. Вместе они закрывают типовую причину DMCA-жалоб.
Проверка после установки:
systemctl is-active torrent-block
iptables -L TORRENT_BLOCK -v -n # ненулевые pkts = фильтр реально срабатываетЭтот репозиторий закрывает установку и штатную эксплуатацию. Для реагирования на инциденты есть отдельный — 3xui-abuse-remediate: если хостер прислал abuse-жалобу, потому что заражённое устройство клиента гоняет трафик к C&C транзитом через твой прокси, тот скрипт блокирует связь с C&C на уровне nftables и routing Xray, включает логи для поиска виновного клиента и проверяет сервер на признаки компрометации.
Инструменты намеренно разделены: установка выполняется один раз на чистой машине, реагирование — на боевой, с живыми клиентами и другим уровнем риска. К тому же в abuse-скрипте зашиты индикаторы конкретного инцидента, которые со временем теряют актуальность, — запускать его превентивно при установке смысла нет.
По умолчанию вход по паролю остаётся включённым: на части провайдеров пароль — единственный способ зайти через VNC, если что-то пойдёт не так.
Флаг --harden-ssh его отключает, но только когда найден непустой
authorized_keys — иначе шаг пропускается, чтобы не потерять доступ к серверу.
Перед перезагрузкой sshd конфиг проверяется через sshd -t, при ошибке правки
откатываются.
Отдельно скрипт обезвреживает чужие drop-in вроде 50-cloud-init.conf. Это важно:
в OpenSSH выигрывает первая встреченная директива, а 50- читается раньше
99-, поэтому без правки cloud-init пароль остался бы включённым, несмотря на
собственный файл.
Раз панель теперь живёт за SSH-туннелем, доступ по SSH становится единственным
входом в управление — тем более имеет смысл держать ключи и --harden-ssh.
Скрипт правит файрвол и ставит стороннее ПО. Перед TrafficGuard (если он включён
флагом) делается бэкап правил в /root/fw-backup/. Цепочка SCANNERS-BLOCK
встаёт перед правилами ufw, включая allow 22 — если твоя сеть попадёт в
блок-листы, доступ по SSH пропадёт. Держи под рукой VNC или rescue-режим.
MIT — см. LICENSE.
Использует сторонние проекты: 3x-ui, traffic-guard, TrafficGuard-auto, reinstall, acme.sh.