golang

YPtun — клиент, который сам поднимает себе сервер

  • суббота, 31 октября 2026 г. в 00:00:13
https://habr.com/ru/articles/1101440/

Обычно свой сервер выглядит так: купил VPS, зашёл по SSH, открыл README, скопировал curl | bash, потом полчаса выясняешь, почему не открывается порт, а в конце переписываешь ключи из терминала в клиент. Мы решили, что раз клиент и так знает, какие параметры нужны серверу, пусть он его сам и ставит. Вводишь IP и пароль от VPS, жмёшь кнопку, через минуту поля подключения уже заполнены.

Ниже о том, как это устроено, и о том, сколько раз оно у нас ломалось.

Это вторая статья про YPtun. Первая, обзорная, здесь: YPtun: один клиент вместо десятка.


Зачем это вообще понадобилось

Часть движков в YPtun работает только со своим сервером, подписку от провайдера к ним не подключишь. Сейчас их семь: VK‑TURN (WDTT Plus) и freeturn гонят WireGuard через TURN‑релеи, MasterDNS туннелирует трафик через DNS‑запросы, olcRTC ходит поверх сервисов видеосвязи, OpenFlux это исследовательский туннель со сменными транспортами, snolc модульный движок на Rust с Noise, и ещё CSQTT.

У каждого свой сервер, своя инструкция и свои сюрпризы. Человек, который разобрался с VLESS, совершенно не обязан знать, как собирается сервер MasterDNS и почему после установки он молчит. Поэтому и появилась кнопка.

Как это выглядит

Слева экран сервера с карточкой «Установить сервер на VPS», справа окно автоустановки
Слева экран сервера с карточкой «Установить сервер на VPS», справа окно автоустановки

Добавляете локацию нужного типа, например MasterDNS. На экране сервера первой стоит карточка «Установить на VPS». Вводите IP, логин и пароль. Можно вместо пароля приватный SSH‑ключ, в том числе с парольной фразой. Жмёте кнопку и смотрите журнал установки вживую. Когда всё закончится, клиент сам впишет в локацию ключи, порты и домен, останется только подключиться.

Так ставится сервер для любого из семи движков, о которых шла речь выше: VK‑TURN, freeturn, MasterDNS, olcRTC, OpenFlux, snolc и CSQTT. Никакой разницы в том, как это выглядит, нет: та же карточка, те же поля, тот же журнал. Внутри у каждого свой скрипт, который знает, как поднять именно этот сервер, и свои собранные бинарники под x86_64 и ARM64, так что подойдёт почти любой VPS. Можно взять один сервер и поставить на него сразу несколько движков, если они не спорят за один порт. А если спорят, установщик скажет, кто этот порт занял, а VK‑TURN в таком случае сам переедет на соседний свободный.

Работает на Android и на ПК (Windows и Linux). На ПК ключ можно просто выбрать файлом.

На iOS приложение тоже есть, пока в бете, но автоустановки там нет. Причина скучная: установщик написан на JVM‑коде с JSch, а на iPhone JVM нет, там Kotlin/Native, и этот код туда просто не переносится. Так что если у вас iPhone, поставьте сервер с Android или с компьютера, а потом нажмите «Поделиться» у локации и откройте ссылку на телефоне. Все поля подтянутся сами.

Раньше эту кнопку никто не мог найти

В комментариях к первой статье нас спросили, где вообще в приложении этот установщик. Вопрос по делу. Раньше автоустановка была выключена по умолчанию, и включать её надо было тумблером где‑то глубоко в настройках. Нам казалось, что залить бинарник на сервер под root достаточно серьёзное действие, чтобы человек включил его осознанно. На деле получилось, что самую полезную функцию просто никто не видел.

В новой версии мы переделали дизайн, и установщик переехал на видное место. Карточка «Установить на VPS» теперь первая на экране сервера у всех движков, которым нужен свой сервер. Ниже идут «Как подключаться» (тип, маршрутизация, ядро) и «Дополнительная защита» с каскадом и WARP, а пинг показывается прямо под именем.

Заодно разобрали настройки. Раньше это был один длинный список, теперь три группы: «Подключение», «Приложение» и «Ещё». Ничего при этом не удалили, просто разложили по местам. Экран добавления стал проще: поле для ссылки с кнопкой «Вставить» и подсказкой, что именно распознано, рядом QR и файл. Если подписки нет, там же бесплатные серверы и создание вручную, где типы подписаны нормальными словами. У подписки вместо всплывающего окна теперь свой экран с трафиком, сроком и ссылкой.

Ну и внешне: скругления помягче, графитовые цвета, никаких серых рамок, круглая кнопка подключения. На Android, iOS и ПК всё выглядит одинаково.

Главный экран и «Создать вручную» в новом дизайне
Главный экран и «Создать вручную» в новом дизайне

Что происходит внутри

Если коротко, это SSH‑клиент (JSch, форк от mwiede) и один shell‑скрипт.

Как проходит автоустановка: от uname -m до заполненных полей локации
Как проходит автоустановка: от uname ‑m до заполненных полей локации

Сначала клиент спрашивает у сервера uname -m, чтобы понять, x86_64 там или aarch64. Под обе архитектуры собранные серверы лежат прямо в приложении. Нужный бинарник в gzip заливается в /tmp, после чего мы сравниваем sha256sum на сервере с хешем у себя. Если sha256sum на сервере нет, сравниваем хотя бы размер.

Потом одной командой запускается установочный скрипт. Он останавливает старую установку, если она была, и проверяет, свободен ли порт. Кладёт бинарник в /usr/local/bin, пишет конфиг и генерирует ключ шифрования, причём только если ключа ещё нет, чтобы после переустановки не отвалились уже настроенные клиенты. Дальше systemd‑юнит с Restart=always, открытие порта в файрволе (ufw, firewalld или голый iptables, что найдётся), перезапуск службы и проверка, что сокет правда слушает.

В конце скрипт печатает ключ строкой вида MASTERDNS_KEY=..., приложение его вытаскивает и подставляет в локацию.

На бумаге всё просто. Дальше про то, где мы спотыкались.

Сервер, который рвал соединение на втором канале

Первая версия была сделана как в учебнике: одно SSH‑соединение, на нём SFTP для файла и exec для команд. У всех работало. А у одного из нас на VPS соединение обрывалось ровно в момент, когда открывался второй канал. uname -m проходил, дальше всё падало.

Что именно там так себя ведёт, мы до конца не поняли. Зато нашли способ, который работал всегда: одно новое соединение на одну маленькую команду. Файл в таком режиме едет через base64 кусками по 100 КБ, и каждый кусок это отдельный printf '...' | base64 -d >> файл в своём соединении.

Почему именно 100 КБ. Команда уходит на сервер одним аргументом sh -c, а Linux ограничивает длину одного аргумента 128 КБ (MAX_ARG_STRLEN). Отрежешь больше, и команда просто не запустится.

Какое‑то время только так и заливали, для всех. Потом посчитали: бинарник на 20 МБ это примерно 270 SSH‑логинов и несколько минут ожидания. Многовато ради одного капризного сервера. Сейчас сначала пробуем обычный SFTP одним файлом, а кусками льём, только если он не сработал. Хеш в обоих случаях сверяем. Раньше недолитый файл доходил до gunzip битым, и по ошибке было вообще непонятно, что случилось.

«Готово», только сервер не работает

Это был самый неприятный баг. Установщик пишет, что всё готово, а сервер не отвечает.

Оказалось, на сервере остался старый dnstt и держал тот же UDP‑порт. Новый MasterDNS при старте падал, systemd его перезапускал, он снова падал, и так по кругу. А мы проверяли systemctl is-active, который между перезапусками честно отвечает active.

Вторая половина проблемы: systemctl enable --now не перезапускает службу, которая уже работает. Так что если она была запущена раньше, новый конфиг просто не применялся. Тоже молча.

Теперь установщик сначала останавливает свою старую службу и проверяет порт через ss. Если порт занят, он пишет, каким процессом и какой службой, и предлагает «Освободить порт». Вместо enable --now делается обычный restart. А успехом считается только сокет, который реально слушает: ss -Hulpn "sport = :ПОРТ" | grep masterdns. Если его нет, в журнал установки выводятся последние строки journalctl.

С VK‑TURN была похожая история. Старый WDTT сидел на порту 56 000 под службой с нестандартным именем, а freeturn по умолчанию слушает тот же 56000. Теперь установщик находит старый WDTT по процессу на порту, как бы его служба ни называлась, и удаляет. А если порт занят чем‑то другим, ставит сервер на следующий свободный порт и сам меняет его в настройках локации.

Ключ Ed25519 и «Auth fail»

По паролю всё входило, а по ключу ed25519, который сейчас у всех самый обычный, Android выдавал Auth fail. В журнале JSch нашлось ssh-ed25519 is not available и curve25519-sha256 is not available.

Дело в том, что JSch выбирает реализацию Ed25519, curve25519 и chacha20 по версии Java. Android сообщает версию ниже 15, и тогда JSch идёт за ними в Bouncy Castle, которого в приложении не было. В итоге ключ серверу даже не предлагался.

Добавили Bouncy Castle и явно переключили JSch на его реализации на всех платформах. На ПК это тоже пригодилось, потому что в урезанной JRE портативной версии этих алгоритмов могло не быть.

«Тот же самый пароль» не подходит

Классика: в терминале пароль подходит, а в приложении нет. Чаще всего причин две.

Первая: лишний пробел или перевод строки. Пароль вставляют из менеджера паролей, а в поле одни точки, ничего не видно. Теперь пробелы по краям обрезаются, а если от этого изменилась длина, в журнале будет об этом строчка.

Вторая: сервер принимает пароль только через keyboard‑interactive. На многих VPS обычный метод password выключен, и пароль спрашивает PAM, иногда в несколько заходов. Мы пробуем оба метода и отвечаем паролем на каждый заход, но не больше четырёх раз, чтобы с неверным паролем всё равно получить внятную ошибку.

Если войти так и не получилось, в ошибке написано, что проверить по порядку: PermitRootLogin, PasswordAuthentication, KbdInteractiveAuthentication. А выше в журнале видно, какие способы входа сервер вообще предложил.

Android сам распаковывает.gz

Серверные бинарники лежат в ресурсах приложения сжатыми, например masterdns-server-linux-amd64.gz. Только при сборке APK Android распаковывает .gz‑ресурсы и хранит их уже без расширения, так что во время работы файла с .gz просто нет.

Теперь загрузчик ищет файл сначала без расширения, потом с .gz. Если нашёлся несжатый, приложение сожмёт его само: так заливка остаётся быстрой, а gunzip на сервере работает как раньше.

Один код на телефон и ПК

Сначала установщик работал только на Android. На ПК вместо него стояли заглушки с текстом «доступно только в Android‑приложении», хотя по сути это обычный JVM‑код, JSch плюс shell‑скрипт.

Мы вынесли его в отдельный набор исходников jvmSharedMain, общий для Android и десктопа. Копия кода теперь одна, отличается только то, откуда брать бинарники: на Android из assets, на ПК из ресурсов.

Про безопасность

Установщик заходит на сервер под root, ставит службы и открывает порты, так что запускать его лучше на чистом VPS, который вы держите под это. Ключ хоста при первом подключении принимается автоматически: у свежего VPS его неоткуда знать заранее, но имейте это в виду. Всё, что подставляется в скрипт (домен, пароль сервера, адреса резолверов), экранируется или фильтруется, чтобы через эти поля нельзя было выбраться из кавычек. Код открыт, установщики лежат в sharedUI/src/jvmSharedMain, скрипты можно прочитать до того, как что‑то запускать.

Что дальше

Следующая статья будет про MasterDNS, туннель на тот случай, когда из сети наружу выпускают только DNS. Ставится он той же кнопкой.