Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

3xui-deploy

Разворачивает 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 оставлен для случаев, когда это осознанное решение. Скрипт предупредит о последствиях, но выполнит.

JSON-паспорт

Выводится в конце установки. Повторно — в любой момент:

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

Бывает, что вместо 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

Ставится только по флагу --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-скрипте зашиты индикаторы конкретного инцидента, которые со временем теряют актуальность, — запускать его превентивно при установке смысла нет.

Про SSH

По умолчанию вход по паролю остаётся включённым: на части провайдеров пароль — единственный способ зайти через 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.

About

Развёртывание VPN-сервера на 3x-ui с VLESS+Reality одной командой: сертификат, BBR, ufw, fail2ban, блокировка сканеров и JSON-паспорт для биллинга

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages