Домашний удалённый доступ без панели: эксперимент с Xray, Docker Compose и локальным CLI

от автора

Как я перестал править 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/ovpnvim xray/config.jsondocker 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-1domain: <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 savedname: family-1role: vpnssh: root@<server-ip>:22domain: <domain>xray: 26.3.27state: ~/.ovpn updated

Дальше первый init:

./ovpn server init family-1

Типовой вывод после успешного применения:

preflight: ssh                                  PASSpreflight: docker                               PASSrender: docker-compose.yml                      OKrender: xray/config.json                        OKrender: agent/.env                              OKupload: /opt/ovpn/.bundle/20260531T082411Z      OKapply: docker compose pull                      OKapply: docker compose up -d                     OKhealth: ovpn-agent                              OKhealth: xray                                    OKdone: family-1 initialized

После init я не считаю стенд готовым, пока не прошёл doctor:

./ovpn doctor family-1
== family-1 ==ssh:                 PASS  root@<server-ip>:22docker:              PASS  26.1.xcompose project:     PASS  /opt/ovpnxray container:      PASS  Up 14sagent health:        PASS  http://127.0.0.1:18080/healthpublic port:         PASS  443/tcp listeningconfig permissions:  PASS  0640 root:xraysecrets permissions: PASS  0600Overall: PASS

doctor — маленькая команда, но психологически она меняет workflow. Без неё deploy заканчивается верой. С ней deploy заканчивается фактом: сервис поднялся, порт слушает, агент отвечает, права на файлы не разъехались.

Первая грабля: «чистый сервер» оказался не чистым

Один тестовый хост уже пережил несколько экспериментов. Я об этом забыл. Init прошёл до момента применения runtime, а потом doctor показал вот такое:

== family-1 ==ssh:                 PASSdocker:              PASScompose project:     PASSxray container:      FAIL  Restarting (1) 8s agoagent health:        PASSpublic port:         FAIL  443/tcp already usedlistener:  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 savedusername: aliceidentity: alice@globalenabled: trueexpiry: 2026-12-31scope: all enabled vpn servers

Генерация клиентской строки и QR:

./ovpn user link --server family-1 --username alice --qr-file ./alice.family-1.png
server: family-1user: aliceclient link: <redacted: personal client link>qr file: ./alice.family-1.pngwarning: link and QR contain a full client credential

QR-код сильно снижает бытовое трение. Когда помогаешь своей маме настроить телефон, «отсканируй код» работает лучше, чем длинная строка в мессенджере.

Потерял ссылку/дала ссылку коллеге на работе

Это самые простые кейсы, но именно они оправдывают персональные доступы.

Надо перевыпустить ссылку и просить обновиться. С отдельной записью операция выглядит так:

./ovpn user disable --username alice./ovpn doctor family-1
user updatedusername: aliceenabled: falseeffective_enabled: falseruntime: user removed from active Xray clients on enabled servers== family-1 ==agent health:        PASSxray container:      PASSquota state:         PASSOverall: 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 savedusername: guest-phoneidentity: guest-phone@globalexpiry: 2026-06-10effective 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 updatedusername: alicewindow: rolling 30dlimit: 300 GBstored_as: bytesscope: all enabled vpn servers

Статус по серверу:

./ovpn stats --server family-1

Демонстрационный формат вывода:

server: family-1window: rolling 30dcollected_at: 2026-05-31T08:20:11ZUSER          USED_30D   QUOTA     USED    STATE        EXPIRESalice         84.6 GB    300 GB    28.2%   ok           2026-12-31bob           292.4 GB   300 GB    97.5%   warn         no-expiryguest-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                 PORTSxray          ghcr.io/xtls/xray-core:...    Up 3 days              0.0.0.0:443->443/tcpovpn-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: upprometheus: healthy        retention=10d scrape=30sgrafana: healthy           dashboards=4alertmanager: healthy      receivers=defaultnode-exporter: healthycadvisor: healthybot 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_bytesovpn_agent_user_window_30d_quota_bytesovpn_agent_user_quota_percentovpn_agent_user_quota_blockedovpn_agent_user_expiry_timestamp_secondsovpn_agent_user_days_until_expiry

Alertmanager здесь нужен не для красоты. Он группирует и дедуплицирует алерты. Если на маленьком хосте одновременно посыпались agent, cAdvisor и несколько scrape targets, оператору не нужны десять одинаковых сообщений. Ему нужен один понятный сигнал: «сервер в плохом состоянии, смотреть сюда, отправить в мессенджер alert». Также добавил возможность перезапуска сервисов в боте.

Вторая грабля: один из пользователей стал себе качать фильмы из тор сети

Тут я не ожидал такого поворота событий и решил ограничить доступ к сетям тор на уровне Ansible и на уровне Xray config.

# Ansible ovpn_block_tor_exit_nodes: trueovpn_tor_exit_block_port: 443ovpn_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 archiveremote: /opt/ovpn-backups/family-1-20260531T082501Z.tgzlocal:  ~/.ovpn/backups/family-1-20260531T082501Z.tgzretention:  remote: keep latest 7  local:  keep latest 7status: 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 unavailablexray: 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 модель, которую я сделал.

Источники

ссылка на оригинал статьи https://habr.com/ru/articles/1042856/