- BrainTools - https://www.braintools.ru -

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

Как я перестал править 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-конфигурацию из локального состояния, выдавать отдельные клиентские строки, отзывать конкретного пользователя и не держать общий секрет на всех.

С точки зрения [1] оператора это даёт несколько удобных свойств:

  • пользователь — это запись в 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: рестарты, память [2], 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». Также добавил возможность перезапуска сервисов в боте.

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

Тут я не ожидал такого поворота событий и решил ограничить доступ к сетям torrent на уровне 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, кладёт Torrent 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 стратегия. Здесь нет магии, которая спасёт от потери локальной машины оператора, провайдера или всех архивов сразу (делайте бэкапы бэкапов). Но для маленького стенда цена ошибки [3] резко падает: перед изменением есть снимок, после изменения есть проверка.

Третья грабля: «безопасные 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 [4]

В следующей статье попробую рассказать про HA модель, которую я сделал.

Источники

Автор: agent-ram

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/31235

URLs in this post:

[1] зрения: http://www.braintools.ru/article/6238

[2] память: http://www.braintools.ru/article/4140

[3] ошибки: http://www.braintools.ru/article/4192

[4] https://github.com/agentram/ovpn: https://github.com/agentram/ovpn

[5] Project X: https://xtls.github.io/

[6] Xray-core repository: https://github.com/XTLS/Xray-core

[7] Источник: https://habr.com/ru/articles/1042856/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1042856

www.BrainTools.ru

Rambler's Top100