- BrainTools - https://www.braintools.ru -
Самый неприятный момент в маленькой домашней инфраструктуре наступает не тогда, когда она падает. Хуже, когда она начинает работать достаточно хорошо, чтобы ей начали пользоваться другие.
Сначала у меня был один сервер, один конфиг и одно устройство. Потом добавился телефон. Потом ноутбук. Потом кто-то из близких попросил «скинуть настройки ещё раз». Потом старое устройство осталось неизвестно где, а в конфиге уже лежало несколько 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 и диагностику.
Я не пытался сделать статью про протокольную магию. Для этого лучше читать документацию Project X и другие статьи на хабре. Мне была важна эксплуатационная сторона.
VLESS в Xray — лёгкий stateless-протокол, где пользовательская идентичность опирается на id/UUID. REALITY живёт в transport security части streamSettings. Практический эффект для моего проекта простой: можно рендерить inbound-конфигурацию из локального состояния, выдавать отдельные клиентские строки, отзывать конкретного пользователя и не держать общий секрет на всех.
С точки зрения [1] оператора это даёт несколько удобных свойств:
пользователь — это запись в 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 экономит больше времени, чем занимает его написание.
Первое правило, которое я для себя зафиксировал: никаких общих клиентских конфигов. У каждого человека должна быть отдельная запись, даже если пользователей всего трое.
Добавление пользователя:
./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-го стало плохо. Использование плавно выходит из окна, и оператор видит тренд, а не только итоговый счётчик.
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 для маленьких стендов. Казалось, что 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 на уровне 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"
}
./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] резко падает: перед изменением есть снимок, после изменения есть проверка.
В какой-то момент я включил более строгий 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 [4]
В следующей статье попробую рассказать про HA модель, которую я сделал.
Project X [5]
ovpn repository [4]
Автор: 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
Нажмите здесь для печати.