Две сущности с одним именем: raw-подписка и subscription page
В Remnawave «подпиской» называют две разные вещи, и их путают в 90% жалоб.
Raw subscription — URL вида https://sub.example.com/<shortUuid>, по которому VPN-приложение забирает конфиг. Бэкенд читает заголовок User-Agent, определяет клиент и отдаёт тело в подходящем формате. shortUuid — это не внутренний UUID пользователя, а укороченный публичный идентификатор подписки; при его пересоздании старая ссылка мгновенно перестаёт работать.
Subscription page — HTML-лендинг, который открывается по тому же адресу из браузера (User-Agent = обычный браузер): кнопки «Добавить в Happ / v2rayNG / Streisand», deep-link'и happ:// и v2rayng://, QR-код, остаток трафика. В актуальных сборках это отдельный контейнер remnawave/subscription-page, который проксирует raw-ответ бэкенда и оборачивает его в интерфейс.
Ключевой практический вывод: приложение и браузер бьют по одному URL, но получают разное тело. Поэтому «в браузере красиво, а в приложении пусто» — это не поломка лендинга, а проблема raw-ответа (шаблон или детект клиента). Диагностику всегда начинайте с сырого ответа, а не с UI:
# что реально отдаёт бэкенд конкретному клиенту: статус, заголовки, тело
curl -i -H 'User-Agent: Happ/1.0' https://sub.example.com/<shortUuid>
Шаблоны подписки: base64, Clash, Sing-box и где ломается прод
Формат вывода задаёт шаблон подписки в панели (Subscription templates). Каждый формат привязан к семейству клиентов по User-Agent:
| Формат | Клиенты | Что внутри |
|---|---|---|
| Base64 (VLESS-список) | v2rayNG, Happ, Streisand, NekoBox | плоский список vless://… в base64 |
| Clash / Mihomo (YAML) | Clash Meta, Mihomo, FlClash | proxies + proxy-groups + rules |
| Sing-box (JSON) | sing-box, Hiddify | outbounds + route + dns |
| Xray JSON | клиенты на голом Xray-core | полный конфиг Xray |
Разница принципиальная. Base64-список — это только ссылки: вся маршрутизация и DNS остаются на дефолтах приложения. А Clash и Sing-box — это полноценный конфиг, где вы сами описываете dns (fake-ip), route/rules, геобазы и селекторы. Именно тут настраивается схема «внутренний трафик РФ мимо VPN, остальное — через ноду», и именно тут чаще всего роняют прод одной правкой: пустой или невалидный шаблон = у клиента «нет серверов», хотя ссылка живая и статус 200.
Happ — особый случай. Он читает base64-список, но многое из «умного» поведения (announce, встроенная маршрутизация, контроль обновления) берёт из своих расширений поверх подписки, а не из шаблона.
Правило безопасной правки шаблона: не редактируйте боевой напрямую. Снимите текущий ответ через curl, поправьте копию, проверьте валидность (sing-box check -c out.json; для Clash — прогон в Mihomo) и только потом заливайте.
Служебные заголовки: трафик, срок, интервал обновления
Remnawave кладёт в HTTP-ответ подписки служебные заголовки — по ним приложение рисует остаток трафика, дату окончания и решает, как часто дёргать URL. Это часть протокола подписок, а не косметика.
subscription-userinfo: upload=…; download=…; total=…; expire=…— трафик и срок.total=0обычно значит «безлимит»,expire— unix-время окончания. Неверный остаток в приложении → смотреть сюда.profile-update-interval— в часах, как часто клиент обновляет подписку. Значение1= раз в час; поставите24— пользователь до суток сидит на старом наборе нод.profile-title— имя профиля (обычно base64), видно в списке подписок.announce— строка-объявление; часть клиентов (в т.ч. Happ) показывает её пользователю. Удобно для сервисных сообщений («сегодня в 03:00 переезд нод»).profile-web-page-url/support-url— ссылки на страницу подписки и в поддержку.
Быстрая проверка, что бэкенд реально их отдаёт и прокси/CDN их не срезал:
curl -sI -H 'User-Agent: v2rayNG/1.9' https://sub.example.com/<shortUuid> \
| grep -iE 'subscription-userinfo|profile-update-interval|profile-title'
Если subscription-userinfo не приходит — приложение просто не покажет трафик и срок, и пользователь решит, что «подписка сломалась», хотя ноды рабочие. Частый виновник — фронт, который вырезает нестандартные заголовки.
Почему подписка «не обновляется» — разбор по частоте
«Не обновляется» почти всегда значит: приложение показывает старый набор серверов или старый трафик, хотя в панели вы уже всё поменяли. По убыванию частоты:
1. Кэш на стороне клиента (самое частое). Приложения обновляют тело не мгновенно, а по profile-update-interval. Отдельно выделяется iOS Happ: по наблюдениям он кэширует особенно упорно и иногда не подтягивает новый ответ даже при ручном обновлении. Что делать по порядку: уменьшить интервал до 1 часа → попросить ручное обновление → как крайняя мера удалить и заново добавить подписку по той же ссылке (это сбрасывает локальный кэш).
2. Кэш на CDN/прокси перед endpoint'ом. Если ответ закэшировался на фронте, устаревший конфиг разъедется сразу по всем пользователям. Путь подписки обязан отдаваться без кэша:
Cache-Control: no-store, no-cache, must-revalidate
Expires: 0
Ставится на уровне прокси/CDN именно для пути подписки, не для статики лендинга.
3. Сменился shortUuid. Пересоздали ключ — старый URL отдаёт 404 или пусто. Проверяется кодом ответа в curl -i.
4. Клиент не распознан по User-Agent. Экзотический или свежеобновлённый клиент может не попасть в правило детекта — прилетает не тот формат или пустое тело. Проверяется подстановкой нужного User-Agent.
5. Часы устройства. Сильный сдвиг времени ломает и показ срока, и TLS-валидацию у самих нод.
Алгоритм: curl -i с UA нужного клиента → есть ли в теле актуальные ноды и свежий subscription-userinfo. Если да — проблема на клиенте (кэш). Если нет — на бэкенде: шаблон, детект или кэш на фронте.
Подписка за CDN: no-store на файл, whitelist-фронт на ноды
Здесь два требования, которые легко перепутать, — и путаница дорого стоит.
Сам файл подписки кэшировать нельзя. Ответ динамический (трафик, срок, актуальный список нод), поэтому на пути подписки обязателен no-store (см. предыдущий раздел). Закэшируете endpoint на CDN — получите массовое «не обновляется» одномоментно у всех.
А транспорт самих нод за CDN, наоборот, полезен. Сервер в дата-центре при белых списках блокируется тривиально: IP ноды уходит в чёрный список ТСПУ, и рабочий конфиг из подписки ведёт на мёртвый адрес. Устойчивость даёт не endpoint, а фронт трафика нод через whitelisted-CDN: клиент коннектится к белому IP CDN, а тот проксирует на вашу ноду — блокировка по IP ноды не роняет сервис. Детально это разобрано в отдельных статьях про панель и ноду за CDN и про XHTTP за CDN; здесь важно лишь развести ответственность.
Один практический нюанс, который порождает жалобы «в одном приложении работает, в другом нет»: XHTTP за CDN, пропускающим только GET, требует явного mode: packet-up в конфиге. Клиент, который сам его не выбрал, не поднимет соединение. Не убирайте packet-up из боевого шаблона ради «упрощения».
Итог: no-store — на путь подписки, кэш — только на статику лендинга, whitelist-CDN — на транспорт нод, а не на файл подписки.
Чек-лист настройки и быстрая диагностика
Настройка с нуля:
- Задан публичный домен подписки (
SUB_PUBLIC_DOMAIN/ соответствующая настройка), TLS-сертификат валиден. - Заполнены шаблоны под нужные клиенты: минимум base64 (v2rayNG/Happ/Streisand), при необходимости — Sing-box и Clash с вашей маршрутизацией.
profile-update-interval= 1 (час), не 24.- Заполнены
profile-title,announce,support-url— пользователю видно, что за подписка и куда писать. - На пути подписки на прокси/CDN выставлен
Cache-Control: no-store. - Subscription page отвечает в браузере и ведёт deep-link'ами в приложения.
Диагностика жалобы:
# 1. что отдаётся конкретному клиенту: статус, заголовки, тело
curl -i -H 'User-Agent: Happ/1.0' https://sub.example.com/<shortUuid>
# 2. служебные заголовки и кэш
curl -sI -H 'User-Agent: Happ/1.0' https://sub.example.com/<shortUuid> \
| grep -iE 'cache-control|subscription-userinfo|profile-update-interval'
# 3. декодировать base64-тело и убедиться, что ноды актуальные
curl -s -H 'User-Agent: v2rayNG/1.9' https://sub.example.com/<shortUuid> | base64 -d | head
Трактовка: тело свежее и no-store на месте, а клиент показывает старое → клиентский кэш (ручное обновление или re-add). Тело устаревшее/пустое → чините шаблон, детект User-Agent или ищите кэш на фронте.