В первой статье цикла я разобрал 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:8080HLS_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 Startdocker-tunnel.ps1 Statusdocker-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-ключ |
использование ключа не по назначению |
|
Firewall VPS |
лишние публичные listeners |
|
Единственный HTTPS reverse proxy |
единая точка TLS и маршрутизации |
|
Фиксированный upstream |
SSRF через управляемый клиентом URL |
|
Серверная авторизация REST и HLS |
обход интерфейса и прямой просмотр видео |
|
Управляемый |
быстрое аварийное отключение публикации |
Главный принцип здесь простой: данные от браузера описывают желаемое действие, но не сетевой маршрут. Клиент может попросить «открыть камеру 1», однако только сервер решает, какой внутренний адрес соответствует этому идентификатору и какие пути разрешено запрашивать.
Публичность — это не только домен и сертификат. Как только домашний сервис становится доступен через VPS, его входные данные начинают формировать незнакомые люди и автоматические сканеры. Если позволить этим данным управлять внутренними сетевыми запросами, обратный туннель действительно превращается в приглашение войти в дом.
В следующей статье вернёмся к серверной архитектуре и посмотрим на Python глазами C++-разработчика: разберём GIL, asyncio, потоки, процессы и правила передачи многомегабайтных кадров без растущей задержки.
ссылка на оригинал статьи https://habr.com/ru/articles/1090924/