ESPNHow a mass Man City player exodus would warp the transfer marketPunchNGF mourns victims of Ondo aircraft crashDaily MaverickSHARE WITH US: Online schools in South Africa: what should parents know?The Jerusalem PostFormer German spy chief detained on suspicion of espionage and treason, Bild reportsZDF heuteAktuelle Pressemitteilungen des ZDFSouth China Morning PostGreenpeace catches Hong Kong geopark ‘golden week’ visitors damaging marine lifeCapital FMGovt fast-tracks passport decentralisation as Malindi, Nyeri offices near completionNHK 社会JR東日本 大雨災害を受け運転規制のあり方を検証へNPRVietnamese police arrest 12 suspected of prowling city streets at night, snatching cats for meatکیهان لندنرئیس پیشین سرویس اطلاعات خارجی آلمان به اتهام جاسوسی بازداشت شدABC NewsTrump says his super PAC will now pay for controversial taxpayer-funded promo adsAntara NewsIndonesia targets Rp3,839 tln in downstreaming investment through 2029
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

Публичный видеосервис на VPS: как не открыть дверь в домашнюю сеть

Translate

В первой статье цикла я разобрал C++-реализацию сервера видеоаналитики, а во второй — настройку системы через браузерный Viewer. Теперь переходим к эксплуатации: вынесем точку входа на VPS, поднимем HTTPS и покажем работающую систему через интернет.

Домашний компьютер при этом можно не выставлять наружу. Он сам устанавливает обратный SSH-туннель к VPS, а публичный Nginx передаёт запросы через этот туннель. Удобно, работает за CGNAT и не требует проброса портов на домашнем роутере.

Но здесь есть неприятный парадокс: правильно настроенный туннель не открывает домашние порты всему интернету, однако уязвимое веб-приложение поверх туннеля способно превратиться в управляемый извне прокси. Тогда злоумышленник входит не через роутер — его добровольно проводит внутрь наш собственный сервер.

Эта статья — разбор того, как такое происходит и как публиковать домашний видеосервис, не превращая VPS в дверь в локальную сеть.

Самое главное

Безопасная схема держится не на одном пароле и не на одном firewall, а на нескольких независимых границах:

  1. Браузер не выбирает адрес внутреннего сервера. Upstream зафиксирован на серверной стороне.

  2. REST, HLS и Viewer дома опубликованы только на 127.0.0.1.

  3. Обратные порты на VPS также слушают только 127.0.0.1.

  4. Из интернета доступны только HTTPS на 443 и административный SSH.

  5. HLS playlist и сегменты требуют авторизации так же, как REST API.

  6. Для туннеля используется отдельный SSH-пользователь и отдельный ограниченный ключ.

  7. Остановка внешнего доступа — управляемая операция, а не закрытие окна терминала.

Если хотя бы браузер может передать серверу произвольный 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 был доступен напрямую на отдельном порту.

Получилось две независимые ошибки:

  1. SSRF можно было вызвать без входа, потому что адрес назначения обрабатывался раньше авторизации.

  2. Зная 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, а домашние контейнеры продолжат работать только локально.

Если есть подозрение на компрометацию ключа:

  1. Остановить туннель дома.

  2. Удалить соответствующую строку из authorized_keys на VPS.

  3. Завершить активные SSH-сессии пользователя туннеля.

  4. Создать новый ключ, не переиспользуя старый.

  5. Проверить журналы SSH, Nginx и Viewer.

  6. Сменить пароли приложения и камер, если они могли попасть в запросы или журналы.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.