Статья 05
Публичный видеосервис на VPS: как не открыть домашнюю сеть
Reverse SSH, Nginx, CGNAT и SSRF: какие независимые границы нужны публичному сервису поверх домашней инфраструктуры.

В первой статье цикла я разобрал C++-реализацию сервера видеоаналитики, а во второй — настройку системы через браузерный Viewer. Теперь переходим к эксплуатации: вынесем точку входа на VPS, поднимем HTTPS и покажем работающую систему через интернет.
Домашний компьютер при этом можно не выставлять наружу. Он сам устанавливает обратный SSH-туннель к VPS, а публичный Nginx передаёт запросы через этот туннель. Удобно, работает за CGNAT и не требует проброса портов на домашнем роутере.
Но здесь есть неприятный парадокс: правильно настроенный туннель не открывает домашние порты всему интернету, однако уязвимое веб-приложение поверх туннеля способно превратиться в управляемый извне прокси. Тогда злоумышленник входит не через роутер — его добровольно проводит внутрь наш собственный сервер.
Эта статья — разбор того, как такое происходит и как публиковать домашний видеосервис, не превращая VPS в дверь в локальную сеть.
Самое главное
Безопасная схема держится не на одном пароле и не на одном firewall, а на нескольких независимых границах:
- Браузер не выбирает адрес внутреннего сервера. Upstream зафиксирован на серверной стороне.
- REST, HLS и Viewer дома опубликованы только на
127.0.0.1. - Обратные порты на VPS также слушают только
127.0.0.1. - Из интернета доступны только HTTPS на 443 и административный SSH.
- HLS playlist и сегменты требуют авторизации так же, как REST API.
- Для туннеля используется отдельный SSH-пользователь и отдельный ограниченный ключ.
- Остановка внешнего доступа — управляемая операция, а не закрытие окна терминала.
Если хотя бы браузер может передать серверу произвольный URL назначения, наличие HTTPS и красивой формы входа не спасает от SSRF.
Архитектура: дом сам звонит на VPS
Рабочая схема выглядит так:
Интернет
│
▼
HTTPS :443
│
▼
Nginx на VPS
│
├── 127.0.0.1:15173 Viewer
├── 127.0.0.1:18080 REST API
└── 127.0.0.1:18888 HLS
▲
│ три канала ssh -R
│
Домашний компьютер
├── 127.0.0.1:15173 Viewer
├── 127.0.0.1:18080 REST API
└── 127.0.0.1:18888 HLS
Входящего SSH-соединения к дому нет. Домашний компьютер сам подключается к VPS и создаёт три reverse forwarding:
-R 127.0.0.1:15173:127.0.0.1:15173
-R 127.0.0.1:18080:127.0.0.1:18080
-R 127.0.0.1:18888:127.0.0.1:18888
Адрес 127.0.0.1 слева означает, что удалённый порт принимает соединения только с самого VPS. Без него и при разрешающем GatewayPorts туннель может начать слушать внешний интерфейс.
Такая схема полезна, но сама по себе она отвечает только на вопрос «кто может напрямую подключиться к порту». Она ничего не говорит о том, какие сетевые запросы разрешено делать приложению после получения обычного HTTPS-запроса.
Как публичный Viewer превратился в прокси
В первоначальной конфигурации браузер отправлял служебный заголовок:
x-server-url: http://clang-server:8080
Viewer брал значение заголовка и использовал его как адрес назначения. Для штатного клиента это выглядело удобно: можно выбрать нужный backend без пересборки сервера.
Но браузер находится под контролем посетителя. Заголовок легко изменить, и сервер послушно выполнит запрос уже к другому адресу. В первоначальной конфигурации таким способом удалось заставить сервер обратиться и к домашнему роутеру 192.168.1.1, и к стороннему публичному сайту, а затем вернуть ответы через приложение. Во втором случае запрос уходил наружу от имени владельца сервера: именно его инфраструктуру сторонний ресурс видел источником обращения.
Это SSRF — Server-Side Request Forgery. Злоумышленник не подключается к роутеру напрямую. Он просит публичный Viewer сделать запрос от своего имени, а Viewer имеет сетевой доступ туда, куда посетитель попасть не может:
Посетитель интернета
│ «сходи по этому адресу»
▼
Публичный Viewer на VPS
│
▼
Обратный SSH-туннель
│
▼
Домашний Viewer / Docker-сеть
│
├── сервер видеоаналитики
├── камера
├── роутер
└── другие локальные устройства
Именно поэтому SSRF опаснее обычной ошибки маршрутизации. Сервер видит внутреннюю сеть с другой позиции доверия. Кроме домашней сети, его можно заставить обращаться к внешним сайтам: в журналах получателя источником будет инфраструктура владельца сервиса.
OWASP рекомендует не принимать от клиента полный URL, если набор допустимых назначений известен. Надёжнее сопоставить короткий идентификатор с адресом на сервере и самостоятельно построить запрос; allowlist предпочтительнее списка запрещённых адресов. Полезно также запретить автоматическое следование редиректам, способным увести разрешённый запрос на другой хост. OWASP: Server Side Request Forgery Prevention
Исправление: клиент выбирает действие, сервер — адрес
Если backend один, адрес вообще не должен приходить из браузера:
DEFAULT_SERVER_URL=http://clang-server:8080
HLS_SERVER_URL=http://clang-server:8888
Viewer читает эти значения из своей серверной конфигурации и игнорирует x-server-url, query-параметры и другие попытки подменить upstream.
Если серверов несколько, клиент может отправить короткий идентификатор:
{ "server": "camera-1" }
А реальное соответствие остаётся только на сервере:
const upstreams = {
"camera-1": "http://clang-server:8080",
"camera-2": "http://rust-server:8080"
};
const upstream = upstreams[request.body.server];
if (!upstream) {
return response.status(404).json({ error: "unknown server" });
}
Это не «валидация URL». URL пользователя вообще не участвует в сетевом запросе. Сервер выбирает схему, host, port и разрешённый путь из собственной конфигурации.
Дополнительно proxy должен принимать только известные маршруты, например /api/v1/*, ограничивать HTTP-методы, размер тела и время ответа. Универсальный маршрут вроде /proxy?url=... для такой системы не нужен.
Почему пароль не закрыл дыру
Внешне система выглядела защищённой: была страница входа, логин и пароль. Но часть proxy-логики выполнялась до проверки сессии, а HLS был доступен напрямую на отдельном порту.
Получилось две независимые ошибки:
- SSRF можно было вызвать без входа, потому что адрес назначения обрабатывался раньше авторизации.
- Зная URL
live.m3u8, можно было смотреть поток, не проходя форму входа.
Авторизация должна проверяться на сервере для каждого защищённого маршрута. Заблокированная кнопка в браузере — только интерфейс, а не граница безопасности.
HLS усложняет задачу тем, что плеер после playlist запрашивает множество сегментов. Проверять нужно и playlist, и сегменты. В нашем варианте Viewer пропускает только известную схему HLS-путей и перед выдачей проверяет Bearer-сессию. Сам порт HLS остаётся на loopback и из интернета недоступен.
Токен лучше передавать в заголовке или защищённой cookie, а не в query string: URL чаще попадают в историю браузера, журналы и аналитику.
Одна публичная дверь вместо пяти
На VPS снаружи должна быть одна прикладная точка входа — HTTPS 443. Порт 80 может существовать только для перенаправления на HTTPS. SSH 22 остаётся административным каналом и защищается отдельно.
Не нужно публиковать Viewer на 8443 и 9443, REST на 8888, HLS на 9888 «для проверки». Каждый дополнительный listener — ещё один путь, где можно забыть TLS, авторизацию, rate limit или обновление конфигурации.
| Порт | Назначение | Доступ из интернета |
|---|---|---|
| 22/tcp | SSH и обратный туннель | только при необходимости, желательно ограничить источники |
| 80/tcp | перенаправление на HTTPS / ACME | разрешён |
| 443/tcp | единственная точка приложения | разрешён |
| Viewer, REST, HLS | внутренние сервисы | запрещён, только loopback |
| Docker API | управление хостом | никогда не публиковать без специально спроектированной защиты |
Nginx направляет публичный запрос только на фиксированный Viewer:
server {
listen 443 ssl http2;
server_name video.example.org;
server_tokens off;
location / {
proxy_pass http://127.0.0.1:15173;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
}
REST и HLS не получают отдельных публичных proxy_pass. Их серверное проксирование, проверка пути и сессии остаются внутри Viewer. server_tokens off убирает номер версии Nginx из заголовка и стандартных страниц ошибок, хотя само по себе это лишь уменьшение лишней информации, а не защита от эксплуатации. Документация Nginx: server_tokens
Loopback нужно указать дважды
На домашнем компьютере Docker Compose публикует сервисы явно на loopback:
services:
server:
ports:
- "127.0.0.1:18080:8080"
- "127.0.0.1:18888:8888"
viewer:
ports:
- "127.0.0.1:15173:5173"
Запись 18080:8080 без host IP обычно означает публикацию на всех интерфейсах. В официальной документации Docker это прямо отмечено: опубликованные порты по умолчанию доступны не только локальному хосту, а привязка к 127.0.0.1 ограничивает их самим хостом. Docker: Port publishing and mapping
Второй loopback указывается в параметрах ssh -R на VPS. Получаются две независимые границы:
- Docker не принимает соединения из домашней LAN;
- SSH не публикует удалённые порты на внешнем интерфейсе VPS.
Нельзя рассчитывать только на UFW. Docker создаёт собственные правила перенаправления, и трафик к опубликованным контейнерным портам может пройти до обычных правил UFW. Поэтому первая защита — не публиковать лишний порт или привязать его к loopback; firewall остаётся дополнительным уровнем. Docker: Packet filtering and firewalls
После запуска нужно проверять фактическое состояние, а не только конфигурационный файл:
Get-NetTCPConnection -State Listen |
Where-Object LocalPort -in 15173,18080,18888 |
Select-Object LocalAddress,LocalPort,OwningProcess
Ожидаемый LocalAddress — 127.0.0.1, а не 0.0.0.0, :: или адрес сетевого адаптера.
На VPS аналогичная проверка:
ss -lntp | grep -E ':(15173|18080|18888)\b'
SSH-ключ должен уметь только создавать нужный туннель
Для постоянного соединения создаётся отдельный пользователь без административных прав и интерактивных задач. Отдельный ключ используется только для Video Analytics.
Запись в authorized_keys можно ограничить:
restrict,port-forwarding,\
permitlisten="127.0.0.1:15173",\
permitlisten="127.0.0.1:18080",\
permitlisten="127.0.0.1:18888" \
ssh-ed25519 <PUBLIC_KEY> video-analytics-tunnel
restrict отключает возможности ключа по умолчанию, port-forwarding возвращает необходимый forwarding, а permitlisten оставляет только три ожидаемых адреса. Эти ограничения описаны в документации OpenSSH для authorized_keys. OpenSSH sshd: restrict и permitlisten
Дополнительный блок для пользователя туннеля:
Match User tunnel-video
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowTcpForwarding remote
GatewayPorts no
X11Forwarding no
AllowAgentForwarding no
PermitTTY no
Перед применением конфигурацию нужно проверить командой sshd -t, а действующую административную сессию не закрывать, пока вход по ключу не проверен во второй сессии. Ошибка в sshd_config не должна превращаться в потерю доступа к VPS.
Парольный вход для пользователя туннеля отключается обязательно. Для администратора также разумно оставить вход по ключу, запретить прямой root-login и ограничить SSH известными IP или VPN, если эксплуатационная схема это позволяет. Fail2ban может уменьшить шум перебора, но не заменяет отключение паролей и ограничение прав ключа.
Туннель — это сервис, а не окно терминала
Закрытие интерактивного окна SSH не гарантирует остановку публикации. Туннель может поддерживать autossh, фоновый PowerShell-процесс, планировщик или watchdog. В проверенной системе именно так и произошло: окно было закрыто, а приложение оставалось доступным.
Поэтому туннель должен иметь явные операции:
docker-tunnel.ps1 Start
docker-tunnel.ps1 Status
docker-tunnel.ps1 Stop
Управляющий сценарий хранит PID созданного им процесса и останавливает только его. Нельзя завершать все ssh.exe на компьютере: среди них могут быть административные сессии и другие туннели.
Для SSH полезны параметры:
-N
-T
-o BatchMode=yes
-o ExitOnForwardFailure=yes
-o ServerAliveInterval=30
-o ServerAliveCountMax=3
-o StrictHostKeyChecking=yes
ExitOnForwardFailure=yes не позволяет сообщить об успешном запуске, если удалённый порт уже занят. Keepalive обнаруживает оборванное соединение, а проверка host key защищает от незаметной подмены VPS.
Аварийная кнопка и порядок действий
Самый быстрый способ закрыть внешний путь, сохранив локальную систему, — остановить управляемый туннель. Nginx начнёт возвращать 502/504, а домашние контейнеры продолжат работать только локально.
Если есть подозрение на компрометацию ключа:
- Остановить туннель дома.
- Удалить соответствующую строку из
authorized_keysна VPS. - Завершить активные SSH-сессии пользователя туннеля.
- Создать новый ключ, не переиспользуя старый.
- Проверить журналы SSH, Nginx и Viewer.
- Сменить пароли приложения и камер, если они могли попасть в запросы или журналы.
Если обнаружена SSRF или открытый HLS, одного исправления кода недостаточно. До завершения проверки публичный доступ лучше отключить: невозможно заранее знать, какие внутренние адреса уже запрашивались и какие данные были получены.
Проверяем систему глазами внешнего пользователя
Проверка «у меня открывается сайт» подтверждает только доступность. Для безопасности нужен отдельный отрицательный чек-лист.
Из сети, не связанной с домом и VPS, нужно убедиться, что:
- открыты только ожидаемые 80/443 и административный SSH;
- прямые подключения к Viewer, REST и HLS завершаются ошибкой;
- анонимный запрос playlist и сегмента получает
401или403; - неизвестный proxy-путь получает
404; - переданный клиентом
x-server-urlигнорируется; - полный URL в query, JSON или заголовке не меняет upstream;
- редирект внутреннего запроса не уводит его на произвольный host;
- после
Stopтуннеля домашние сервисы перестают быть доступны с сайта; - после перезапуска Viewer политика доступа не сбрасывается.
Отдельно проверяются обе адресные семьи. Сервис, закрытый на IPv4, может неожиданно слушать [::] и быть доступным по IPv6.
Защита слоями
Итоговая схема не должна зависеть от одного идеального компонента:
| Граница | От какой ошибки защищает |
|---|---|
| Loopback Docker дома | случайная публикация REST/HLS в домашнюю LAN |
| Исходящий SSH-туннель | отсутствие входящего порта на домашнем роутере |
Loopback ssh -R на VPS |
прямой доступ извне к туннельным портам |
| Ограниченный SSH-ключ | использование ключа не по назначению |
| Firewall VPS | лишние публичные listeners |
| Единственный HTTPS reverse proxy | единая точка TLS и маршрутизации |
| Фиксированный upstream | SSRF через управляемый клиентом URL |
| Серверная авторизация REST и HLS | обход интерфейса и прямой просмотр видео |
Управляемый Stop |
быстрое аварийное отключение публикации |
Главный принцип здесь простой: данные от браузера описывают желаемое действие, но не сетевой маршрут. Клиент может попросить «открыть камеру 1», однако только сервер решает, какой внутренний адрес соответствует этому идентификатору и какие пути разрешено запрашивать.
Публичность — это не только домен и сертификат. Как только домашний сервис становится доступен через VPS, его входные данные начинают формировать незнакомые люди и автоматические сканеры. Если позволить этим данным управлять внутренними сетевыми запросами, обратный туннель действительно превращается в приглашение войти в дом.
В следующей статье вернёмся к серверной архитектуре и посмотрим на Python глазами C++-разработчика: разберём GIL, asyncio, потоки, процессы и правила передачи многомегабайтных кадров без растущей задержки.