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

Нейтральная схема стенда: operator laptop -> SSH/SCP -> Xray host -> monitoring
Нейтральная схема стенда: operator laptop -> SSH/SCP -> Xray host -> monitoring

Самый неприятный момент в маленькой домашней инфраструктуре наступает не тогда, когда она падает. Хуже, когда она начинает работать достаточно хорошо, чтобы ей начали пользоваться другие.

Сначала у меня был один сервер, один конфиг и одно устройство. Потом добавился телефон. Потом ноутбук. Потом кто-то из близких попросил «скинуть настройки ещё раз». Потом старое устройство осталось неизвестно где, а в конфиге уже лежало несколько 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?

Критерии успеха были такими:

  1. Сервер не является источником правды. Его можно пересобрать из локального состояния.

  2. Пользовательские операции не требуют ручного редактирования Xray config.

  3. У каждого пользователя отдельная идентичность, срок действия (expiration) и квота.

  4. Любой deploy заканчивается проверкой здоровья сервера, а не ожиданием, что всё прошло успешно.

  5. Есть backup, после неудачного изменения или для воспроизведения состояния — restore.

  6. Monitoring можно включить без переписывания архитектуры + bot для read-only операций и мониторинга.

  7. На сервере наружу торчит только то, что действительно нужно.

План звучит крупнее, чем сам стенд. В этом и был смысл эксперимента: проверить, где заканчивается «домашняя автоматизация» и начинается нормальная эксплуатационная модель приближённая к 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 можно проверять и деплоить как артефакт;

  • статистику можно привязать к пользовательским меткам, а не угадывать по устройствам.

Архитектура

Архитектура: локальный CLI, состояние, SSH/SCP, Docker Compose runtime, Xray, agent и monitoring
Архитектура: локальный CLI, состояние, SSH/SCP, Docker Compose runtime, Xray, agent и monitoring

Схема получилась следующей:

Слой

Роль

Машина оператора

ovpn CLI, локальное состояние ~/.ovpn, список серверов, пользователи, квоты, генерация runtime-файлов

SSH/SCP

Control plane: доставка bundle и выполнение команд на сервере

Сервер

Linux-хост с Docker Compose runtime в /opt/ovpn

Xray

Приём пользовательских подключений и применение сгенерированной конфигурации

ovpn-agent

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

Жизненный цикл пользователя: add -> link/QR -> quota -> expiry -> disable/remove
Жизненный цикл пользователя: add -> link/QR -> quota -> expiry -> disable/remove

Первое правило, которое я для себя зафиксировал: никаких общих клиентских конфигов. У каждого человека должна быть отдельная запись, даже если пользователей всего трое.

Добавление пользователя:

./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
Демонстрационный график rolling 30d quota usage
Демонстрационный график rolling 30d quota usage

Я сознательно не стал начинать со 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

Нужен ли doctor

Да, сразу

Успешный 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 модель, которую я сделал.

Источники

Комментарии (0)