Как я перестал править Xray по SSH и собрал маленький control plane без панели

Самый неприятный момент в маленькой домашней инфраструктуре наступает не тогда, когда она падает. Хуже, когда она начинает работать достаточно хорошо, чтобы ей начали пользоваться другие.
Сначала у меня был один сервер, один конфиг и одно устройство. Потом добавился телефон. Потом ноутбук. Потом кто-то из близких попросил «скинуть настройки ещё раз». Потом старое устройство осталось неизвестно где, а в конфиге уже лежало несколько UUID с названиями, которые я придумал в полночь и больше не понимал.
На этом этапе обычно появляется знакомый выбор: открыть SSH, поправить JSON руками, рестартануть контейнер и пообещать себе «потом нормально оформлю». Через пару месяцев «потом» превращается в маленький прод: пользователи, секреты, квоты, бэкапы, мониторинг, логи, обновления и вопрос, какие изменения на сервере были осознанными, а какие — следами экспериментов.
Я не хотел ставить веб-панель. Не из принципа. Панель решает много бытовых задач, но вместе с ней появляются база, авторизация, обновления, публичная поверхность, роли, миграции и отдельная эксплуатация самой панели. Для одного оператора и нескольких серверов это выглядело тяжелее, чем сама исходная задача.
Но и жить в режиме ssh -> vim -> docker compose restart мне тоже не хотелось.
Поиск по open source проектам — не дал результата. Многое из того, что я пытался ставить, либо не запускалось, либо было написано на bash, где было много хардкода, который приходилось переписывать под мои VDS. Потратив несколько вечеров — я понял, что есть запрос на простое решение по оркестрации self-hosted VPN серверов с локальным хранилищем и удобным масштабированием.
Так появился ovpn: локальный Go CLI для управления self-hosted Xray-стендом через SSH/SCP. На сервере — обычный Docker Compose runtime в /opt/ovpn. У оператора — локальное состояние в ~/.ovpn, команды для пользователей, квот, бэкапов, мониторинга и диагностики.
Все примеры ниже обезличены. Адреса, домены, ключи, токены, клиентские строки и QR-коды заменены placeholders. Материал про частную лабораторную инфраструктуру, эксплуатационные решения и ограничения такого подхода. Это не коммерческое предложение и не обещание каких-либо свойств за пределами описанного стенда.
Гипотеза
Формулировка, с которой я начал:
Можно ли построить управляемый стенд удалённого доступа без веб-панели и без хранения desired state на сервере, если применить к маленькой инфраструктуре обычные практики эксплуатации: воспроизводимый deploy, health-check, персональные доступы, квоты, мониторинг, бэкапы и rollback?
Критерии успеха были такими:
Сервер не является источником правды. Его можно пересобрать из локального состояния.
Пользовательские операции не требуют ручного редактирования Xray config.
У каждого пользователя отдельная идентичность, срок действия (expiration) и квота.
Любой deploy заканчивается проверкой здоровья сервера, а не ожиданием, что всё прошло успешно.
Есть backup, после неудачного изменения или для воспроизведения состояния — restore.
Monitoring можно включить без переписывания архитектуры + bot для read-only операций и мониторинга.
На сервере наружу торчит только то, что действительно нужно.
План звучит крупнее, чем сам стенд. В этом и был смысл эксперимента: проверить, где заканчивается «домашняя автоматизация» и начинается нормальная эксплуатационная модель приближённая к big tech разработке (откуда я сам).
Что не устраивало в ручной схеме
Ручная схема выглядела примерно так:
ssh root@<server-ip> cd /opt/ovpn vim xray/config.json docker compose restart xray
Иногда перед этим я ещё пытался вспомнить, кому принадлежит конкретный пользователь:
jq '.inbounds[0].settings.clients[] | {email, id}' \ /opt/ovpn/xray/config.json
Пока пользователей один-два, это терпимо. Потом начинаются мелочи:
временный доступ забыли удалить;
старое устройство осталось активным;
один человек внезапно съел заметную часть месячного трафика маленького сервера;
после обновления контейнер не поднялся, но deploy-команда уже вернула успешный exit code;
мониторинг включили «потом», а «потом» наступило в момент, когда пришло письмо от хостера об использовании большого количества трафика;
backup существовал в голове, но не в файловой системе.
Главная проблема здесь не Xray и не Docker. Главная проблема — отсутствие модели состояния. Сервер постепенно становится блокнотом, в который записывали мысли в разное время и разным настроением.
Какие варианты я рассматривал
Вариант |
Почему не подошёл как основной |
|---|---|
Готовая веб-панель |
Быстро даёт UI, но добавляет отдельную поверхность, базу, auth, обновления и эксплуатацию самой панели. Для одного оператора это перебор. |
Набор shell-скриптов |
Отлично для первого вечера. Плохо, когда появляются состояние, expiry, квоты, выводы команд, тесты и аккуратная обработка ошибок. |
Только Ansible |
Хорош для baseline-харда и пакетов, но неудобен как интерактивный инструмент: «добавь пользователя», «покажи QR», «проверь quota», «сними status». |
Kubernetes |
Слишком много moving parts для одного маленького хоста. Здесь не нужен scheduler, нужен предсказуемый runtime. |
Локальный CLI + SSH/SCP + Docker Compose |
Минимальная новая инфраструктура. Desired state у оператора, сервер остаётся простым Linux-хостом, runtime можно смотреть обычными командами. |
В итоге Ansible остался для подготовки хоста, а ovpn взял на себя runtime: Xray config, пользователи, квоты, deploy, monitoring, backup и диагностику.
Почему именно Xray, VLESS и REALITY
Я не пытался сделать статью про протокольную магию. Для этого лучше читать документацию Project X и другие статьи на хабре. Мне была важна эксплуатационная сторона.
VLESS в Xray — лёгкий stateless-протокол, где пользовательская идентичность опирается на id/UUID. REALITY живёт в transport security части streamSettings. Практический эффект для моего проекта простой: можно рендерить inbound-конфигурацию из локального состояния, выдавать отдельные клиентские строки, отзывать конкретного пользователя и не держать общий секрет на всех.
С точки зрения оператора это даёт несколько удобных свойств:
пользователь — это запись в desired state (sqlite), а не ручной фрагмент JSON;
клиентская строка и QR генерируются командой, а не собираются руками;
revoke/disable не затрагивает остальных пользователей;
config можно проверять и деплоить как артефакт;
статистику можно привязать к пользовательским меткам, а не угадывать по устройствам.
Архитектура

Схема получилась следующей:
Слой |
Роль |
|---|---|
Машина оператора |
|
SSH/SCP |
Control plane: доставка bundle и выполнение команд на сервере |
Сервер |
Linux-хост с Docker Compose runtime в |
Xray |
Приём пользовательских подключений и применение сгенерированной конфигурации |
|
Runtime-информация, health, статистика, quota enforcement |
Monitoring stack |
Prometheus, Grafana, Alertmanager, node_exporter, cAdvisor, опциональный bot для алертов |
Ключевой принцип: сервер — применённое состояние, но не источник правды.
Локально оператор выполняет команду, CLI рендерит runtime, отправляет файлы на сервер, применяет Compose и запускает проверки.
Первый прогон
Для примеров буду использовать такие обозначения:
server: family-1 domain: <domain> host: <server-ip> user: alice
Сначала я регистрирую сервер в локальном состоянии:
# SSH key was already uploaded to the server ./ovpn server add \ --name family-1 \ --host <server-ip> \ --domain <domain> \ --ssh-user root \ --ssh-port 22 \ --xray-version 26.3.27
Демонстрационный вывод:
server saved name: family-1 role: vpn ssh: root@<server-ip>:22 domain: <domain> xray: 26.3.27 state: ~/.ovpn updated
Дальше первый init:
./ovpn server init family-1
Типовой вывод после успешного применения:
preflight: ssh PASS preflight: docker PASS render: docker-compose.yml OK render: xray/config.json OK render: agent/.env OK upload: /opt/ovpn/.bundle/20260531T082411Z OK apply: docker compose pull OK apply: docker compose up -d OK health: ovpn-agent OK health: xray OK done: family-1 initialized
После init я не считаю стенд готовым, пока не прошёл doctor:
./ovpn doctor family-1
== family-1 == ssh: PASS root@<server-ip>:22 docker: PASS 26.1.x compose project: PASS /opt/ovpn xray container: PASS Up 14s agent health: PASS http://127.0.0.1:18080/health public port: PASS 443/tcp listening config permissions: PASS 0640 root:xray secrets permissions: PASS 0600 Overall: PASS
doctor — маленькая команда, но психологически она меняет workflow. Без неё deploy заканчивается верой. С ней deploy заканчивается фактом: сервис поднялся, порт слушает, агент отвечает, права на файлы не разъехались.
Первая грабля: «чистый сервер» оказался не чистым
Один тестовый хост уже пережил несколько экспериментов. Я об этом забыл. Init прошёл до момента применения runtime, а потом doctor показал вот такое:
== family-1 == ssh: PASS docker: PASS compose project: PASS xray container: FAIL Restarting (1) 8s ago agent health: PASS public port: FAIL 443/tcp already used listener: tcp LISTEN 0.0.0.0:443 users:(('nginx',pid=812,fd=6)) Overall: FAIL
Без проверки я бы увидел проблему только на клиенте. С проверкой причина оказалась на поверхности: старый nginx слушал 443 порт.
Фикс был тривиальным:
ssh root@<server-ip> 'systemctl stop nginx && systemctl disable nginx' ./ovpn deploy family-1 ./ovpn doctor family-1
Вывод из этой истории скучный, но полезный: маленький preflight экономит больше времени, чем занимает его написание.
Пользовательский lifecycle

Первое правило, которое я для себя зафиксировал: никаких общих клиентских конфигов. У каждого человека должна быть отдельная запись, даже если пользователей всего трое.
Добавление пользователя:
./ovpn user add --username alice --expiry 2026-12-31
user saved username: alice identity: alice@global enabled: true expiry: 2026-12-31 scope: all enabled vpn servers
Генерация клиентской строки и QR:
./ovpn user link --server family-1 --username alice --qr-file ./alice.family-1.png
server: family-1 user: alice client link: <redacted: personal client link> qr file: ./alice.family-1.png warning: link and QR contain a full client credential
QR-код сильно снижает бытовое трение. Когда помогаешь своей маме настроить телефон, «отсканируй код» работает лучше, чем длинная строка в мессенджере.
Потерял ссылку/дала ссылку коллеге на работе
Это самые простые кейсы, но именно они оправдывают персональные доступы.
Надо перевыпустить ссылку и просить обновиться. С отдельной записью операция выглядит так:
./ovpn user disable --username alice ./ovpn doctor family-1
user updated username: alice enabled: false effective_enabled: false runtime: user removed from active Xray clients on enabled servers == family-1 == agent health: PASS xray container: PASS quota state: PASS Overall: PASS
Если ссылка нашлась и риск снят:
./ovpn user enable --username alice
Если доступ больше не нужен:
./ovpn user rm --username alice
Никакого поиска UUID в JSON, никакой ручной правки.
Временный доступ без календарных напоминаний
В маленьком стенде часто появляются временные сущности: тестовый телефон, поездка на пару недель, друг, которому "только попробовать".
Для этого я использую date-only expiry:
./ovpn user add --username guest-phone --expiry 2026-06-10
user saved username: guest-phone identity: guest-phone@global expiry: 2026-06-10 effective access: enabled until end of UTC day
Мне нравится именно date-only модель. Она не идеальна для большой компании с часовыми поясами и SLA, но для маленького стенда хорошо совпадает с человеческой фразой «до десятого числа включительно».
В monitoring это превращается в простую штуку: показать пользователей, у которых срок заканчивается в ближайшие два дня, и не держать это в голове.

Квоты: эксплуатационный предохранитель, а не наказание
Вторая бытовая проблема появляется не в момент падения сервера, а в момент фразы «что-то стало медленно». На маленьком хосте один пользователь может неожиданно занять заметную долю ресурса, а остальные узнают об этом постфактум.
В ovpn квота — rolling window за последние 30 дней.
./ovpn user quota-set --username alice --monthly-gb 300
quota policy updated username: alice window: rolling 30d limit: 300 GB stored_as: bytes scope: all enabled vpn servers
Статус по серверу:
./ovpn stats --server family-1
Демонстрационный формат вывода:
server: family-1 window: rolling 30d collected_at: 2026-05-31T08:20:11Z USER USED_30D QUOTA USED STATE EXPIRES alice 84.6 GB 300 GB 28.2% ok 2026-12-31 bob 292.4 GB 300 GB 97.5% warn no-expiry guest-phone 8.1 GB 30 GB 27.0% ok 2026-06-10

Я сознательно не стал начинать со speed limiting, так как закопался в этом, архитектура получалась overcomplicated, убил один вечер и решил удалить все изменения.
Rolling window оказался удобнее календарного месяца. Не нужно объяснять, почему 1-го числа всё резко «обнулилось», а 31-го стало плохо. Использование плавно выходит из окна, и оператор видит тренд, а не только итоговый счётчик.
Runtime в Docker Compose
Docker Compose здесь — компромисс между «один systemd unit на всё» и полноценным оркестратором.
На сервере runtime остаётся читаемым:
ssh root@<server-ip> 'cd /opt/ovpn && docker compose ps'
NAME IMAGE STATUS PORTS xray ghcr.io/xtls/xray-core:... Up 3 days 0.0.0.0:443->443/tcp ovpn-agent ovpn-agent:local Up 3 days (healthy) 127.0.0.1:18080
Логи тоже легко получить с локальной машины:
./ovpn server logs family-1 --service xray --tail 80 ./ovpn server logs family-1 --service ovpn-agent --tail 80
Compose хорош именно своей приземлённостью: YAML, сервисы, volumes, networks, lifecycle-команды, понятный ps, понятный logs. Для маленького хоста этого достаточно. Kubernetes в такой задаче не добавил бы надёжности, зато добавил бы новую систему для сопровождения.
Monitoring: включить до того, как он понадобится
Я долго откладывал monitoring для маленьких стендов. Казалось, что SSH и docker logs достаточно. Потом замечаешь, что вопросы стали другими:
контейнер перезапускался ночью или нет?
место на диске заканчивается линейно или скачком?
агент собирает статистику или умер молча?
кто близко к quota?
есть ли пользователи с истекающим доступом?
алерты дублируются или группируются?
В ovpn monitoring включается отдельной операцией:
./ovpn deploy family-1 ./ovpn server monitor up family-1 ./ovpn server monitor status family-1
monitoring: up prometheus: healthy retention=10d scrape=30s grafana: healthy dashboards=4 alertmanager: healthy receivers=default node-exporter: healthy cadvisor: healthy bot relay: disabled
Grafana наружу я не открываю. Для просмотра достаточно SSH tunnel:
ssh -L 3000:127.0.0.1:3000 root@<server-ip>
Минимальный набор dashboard-панелей, который оказался полезен:
host overview: CPU, memory, disk, inode usage, прогноз заполнения диска;
containers overview: рестарты, память, CPU по сервисам;
agent overview: health, collector errors, runtime operations;
user statistics: rolling traffic, quota percent, blocked state, expiry.
Пользовательские метрики выглядят примерно так:
ovpn_agent_user_window_30d_usage_bytes ovpn_agent_user_window_30d_quota_bytes ovpn_agent_user_quota_percent ovpn_agent_user_quota_blocked ovpn_agent_user_expiry_timestamp_seconds ovpn_agent_user_days_until_expiry
Alertmanager здесь нужен не для красоты. Он группирует и дедуплицирует алерты. Если на маленьком хосте одновременно посыпались agent, cAdvisor и несколько scrape targets, оператору не нужны десять одинаковых сообщений. Ему нужен один понятный сигнал: «сервер в плохом состоянии, смотреть сюда, отправить в мессенджер alert». Также добавил возможность перезапуска сервисов в боте.
Вторая грабля: один из пользователей стал себе качать фильмы из тор сети
Тут я не ожидал такого поворота событий и решил ограничить доступ к сетям тор на уровне Ansible и на уровне Xray config.
# Ansible ovpn_block_tor_exit_nodes: true ovpn_tor_exit_block_port: 443 ovpn_tor_exit_list_url: "https://check.torproject.org/torbulkexitlist" ovpn_tor_update_schedule: "daily" # Bash script выполняется по cron, кладёт Tor IPs exit list в ipset # Затем эти IP блокируются iptables -I INPUT -p tcp --dport "$BLOCK_PORT" \ -m set --match-set "$SET_NAME" src -j DROP
# Xray config { "type": "field", "protocol": ["bittorrent"], "outboundTag": "block" }, { "type": "field", "domain": ["geosite:category-public-tracker"], "outboundTag": "block" }
Backup и rollback
./ovpn server backup family-1
backup: create remote archive remote: /opt/ovpn-backups/family-1-20260531T082501Z.tgz local: ~/.ovpn/backups/family-1-20260531T082501Z.tgz retention: remote: keep latest 7 local: keep latest 7 status: done
Перед рискованным изменением мой порядок такой:
./ovpn server backup family-1 ./ovpn deploy family-1 ./ovpn doctor family-1 ./ovpn server status family-1
Если после deploy всё плохо:
./ovpn server restore family-1 \ --remote-path /opt/ovpn-backups/family-1-20260531T082501Z.tgz ./ovpn doctor family-1
Это не полноценная disaster recovery стратегия. Здесь нет магии, которая спасёт от потери локальной машины оператора, провайдера или всех архивов сразу (делайте бэкапы бэкапов). Но для маленького стенда цена ошибки резко падает: перед изменением есть снимок, после изменения есть проверка.
Третья грабля: «безопасные defaults» тоже ломаются
В какой-то момент я включил более строгий security profile и получил падение Xray на одном из образов из-за ресурсов, которые этот образ не смог валидировать. Симптом выглядел как обычный failed restart контейнера, пока не открыл логи.
./ovpn server logs family-1 --service xray --tail 60
xray: failed to load routing resource: geosite data unavailable xray: config validation failed
Для такого случая у проекта есть аварийный путь: временно откатить профиль и вернуть runtime в рабочее состояние, а потом спокойно разбираться с образом и ресурсами.
export OVPN_SECURITY_PROFILE=off ./ovpn deploy family-1 ./ovpn doctor family-1
Мне нравится этот пример тем, что он хорошо показывает цену «безопасных defaults». Любое правило, фильтр или routing-политика должны иметь понятный способ диагностики и выключения (через переменные окружения). Иначе в инциденте оператор начнёт удалять случайные куски конфига руками.
Права, секреты и поверхность
На бумаге проект выглядит как «запустить Xray». В эксплуатации большая часть риска живёт рядом:
/opt/ovpn/xray/config.json root:<xray-runtime-group> 0640 /opt/ovpn/.env root:root 0600
Конфиг содержит чувствительные данные. Env-файлы содержат runtime-секреты. Клиентская строка и QR дают полный доступ конкретного пользователя. Логи не должны превращаться во второе хранилище секретов.
Базовые правила, которые я оставил для себя:
SSH key-only auth;
явная политика root-доступа;
firewall и fail2ban на baseline-слое;
Debian unattended security upgrades без внезапной перезагрузки;
monitoring endpoints не публикуются наружу;
Grafana открывается через SSH tunnel;
Отдельная неприятная деталь: локальная машина оператора становится критичным местом. Там лежит desired state. Значит, её backup, disk encryption и доступы — часть архитектуры, а не личная гигиена «когда-нибудь потом».
Что получилось проверить
Проверка |
Наблюдение |
Вывод |
|---|---|---|
Можно ли жить без панели |
Да, если оператор один или их мало, а все изменения идут через CLI |
Панель не обязательна для маленького стенда, но CLI должен быть дисциплинированным |
Можно ли держать state локально |
Да, но локальная машина становится критичным asset |
Нужны backup/export и понятная история изменений |
Достаточно ли Docker Compose |
Для одного хоста — да |
Главное не pretending-to-be-Kubernetes, а предсказуемый lifecycle |
Нужен ли |
Да, сразу |
Успешный deploy без проверки здоровья — слишком слабый сигнал |
Нужны ли квоты при малом числе пользователей |
Да |
Они показывают аномалии раньше, чем пользователь напишет «что-то не работает» |
Нужен ли monitoring |
Да, но включаемый отдельным профилем |
Маленький стенд тоже должен отвечать на вопросы «что сломалось?» и «когда началось?» |
Достаточен ли backup |
Команда backup полезна, но restore надо периодически репетировать |
Backup без restore drill легко превращается в украшение |
Главный сюрприз: Xray-конфиг оказался не самой сложной частью. Сложнее оказалось оформить вокруг него нормальный операторский workflow.
Где этот подход ломается
Я бы не тащил такую архитектуру в сценарий, где:
много операторов с разными правами;
нужен SSO/RBAC;
серверов сотни, а не десятки или единицы;
локальная машина оператора не может быть доверенным местом хранения state;
есть жёсткие требования к change approval и разделению ролей.
Там почти неизбежно появится серверный control plane, база, роли, журнал изменений и нормальный UI. Мой проект сознательно остаётся в другой зоне: один оператор, несколько хостов, воспроизводимый runtime, минимум публичных компонентов.
Что я бы добавил дальше
Список после первых прогонов получился не про «ещё больше фич», а про снижение цены ошибок:
diffи check/review для сгенерированного Xray config перед deploy;шифрование чувствительной части local state;
профили monitoring под размер хоста;
аккуратная модель для двух операторов без полноценной панели;
Итог
Я начинал с раздражения от ручного редактирования JSON по SSH. В итоге получил маленький control plane: локальное состояние, рендер runtime, Docker Compose на сервере, персональные пользователи, expiry, rolling quota, doctor, monitoring, backup и restore.
Самый ценный результат — не набор команд. Ценнее оказалось изменение отношения: домашний стенд перестал быть «сервером, который я когда-то настроил» и стал системой с понятным жизненным циклом.
Можно спорить, где проходит граница между разумной автоматизацией и избыточным инженерным зудом. Для меня она проходит здесь: если я не могу безопасно и быстро отключить пользователя, понять состояние сервиса, увидеть приближение к лимиту и восстановиться после неудачного deploy, значит, система ещё не сопровождаемая.
Исходный код эксперимента: https://github.com/agentram/ovpn
В следующей статье попробую рассказать по HA модель, которую я сделал.