Skip to content

feat(happ): split-профиль клиента из тех же наборов, что и bypass - #23

Open
tony-adamson wants to merge 4 commits into
howdeploy:mainfrom
tony-adamson:feat/happ-client-split-routing
Open

feat(happ): split-профиль клиента из тех же наборов, что и bypass#23
tony-adamson wants to merge 4 commits into
howdeploy:mainfrom
tony-adamson:feat/happ-client-split-routing

Conversation

@tony-adamson

Copy link
Copy Markdown

Проблема

В проекте уже есть кураторские наборы доменов для bypass: банки, маркетплейсы, Госуслуги, ВК,
Яндекс, Mail.ru, стриминги, Steam. Это ровно список «сюда не надо через VPN», собранный вручную.

Но bypass — серверное правило: оно меняет то, куда запрос отправляет VPS. Клиент всё равно
заворачивает трафик в туннель, поэтому адресат видит адрес VPS. А на ноде без каскада общий outbound
и так direct, то есть bypass там не меняет вообще ничего.

Практический результат: пользователь включает bypass с группой «маркетплейсы» и всё равно получает
от Ozon заглушку. Убрать её может только профиль на стороне клиента.

Что в PR

Пункт HAPP-подписка → 7) Маршрутизация клиента, который пишет .happ_routing.json из тех же
наборов, что и серверный bypass. Обе половины разделения начинают опираться на один список.

Важно, что GlobalProxy остаётся "true": незнакомый заблокированный сервис продолжает идти через
туннель и работать, а напрямую уходят только наборы и geoip:ru. Обратная полярность (по умолчанию
direct плюс список того, что проксировать) выглядит заманчиво, но там любой забытый сервис молча
ломается сразу у всех пользователей. Мы на это наступили дважды, на Telegram и на WhatsApp.

Ссылки на geo-базы используют подстановки из предыдущего PR, поэтому общий профиль не содержит чужой
токен. Предыдущее переопределение бэкапится, удаление файла возвращает дефолт.

Поверх #22.

Проверено

  • bash -n на всех скриптах
  • генератор выдаёт 258 доменов, среди них ozon.ru, sberbank.ru, gosuslugi.ru, vk.com
  • профиль отбраковывается _happ_validate_routing_json, пока плейсхолдеры не разрешены, и
    принимается после подстановки

🤖 Generated with Claude Code

https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg

tony-adamson and others added 4 commits September 7, 2026 13:18
The subscription endpoint ships a client-side routing profile in the routing:
response header, and the managed default tunnels everything. The override file
that changes it was mentioned in one line of the architecture docs, without a
schema, an example, or the difference from server-side bypass routing.

Bypass routing only changes where the server sends a request; the client still
tunnels it, so a Russian site keeps seeing the VPS address. On a node without a
cascade the catch-all outbound is already direct, so bypass cannot help there.
Split routing is only reachable through the client profile, which made this the
most common thing operators had to rediscover on their own.

Adds a Configuration section in en/ru/zh-CN covering the two environment
variables, GlobalProxy semantics, a complete split-tunnel example, and the three
mistakes that are easy to make: Telegram matching by IP rather than domain,
LastUpdated needing to grow, and geo database URLs being per-subscriber.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg
… request

The managed routing profile points HAPP at /sub/<token>/geoip.dat, which is
scoped to one subscriber. HAPP_ROUTING_JSON_FILE, in contrast, is served to
every subscriber, so an operator who wants a custom profile has to either embed
one subscriber's token in a file everyone receives, or host the geo databases
somewhere else entirely.

Adds {{GEOIP_URL}}, {{GEOSITE_URL}} and {{LAST_UPDATED}} placeholders that
subhttp.sh resolves per request. Substitution runs before schema validation, so
an unresolved placeholder fails the https_url check and the managed default is
served rather than a broken profile — including the local-only case where no
public base URL exists.

{{LAST_UPDATED}} resolves to the newest mtime among xrayebator and the two geo
databases, which removes the manual step of raising the value after every edit;
HAPP re-imports a profile only when it grows.

Verified: bash -n on xrayebator and on the generated subhttp.sh; substitution
checked for the resolved, no-placeholder and empty-base-url cases.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg
…ass bundles

Bypass routing already carries curated bundles of Russian services that should
not travel through the VPN: banks, marketplaces, gosuslugi, VK, Yandex, Mail.ru,
streaming, Steam. Those bundles only affect the server's own egress though. The
client keeps tunnelling the request, so on a node without a cascade — where the
catch-all outbound is already direct — Ozon, the banks and Gosuslugi still see
the VPS address and answer with a country stub.

Adds 'HAPP subscription -> 7) Маршрутизация клиента', which writes
.happ_routing.json from the very same bundles, so both halves of the split are
driven by one list. GlobalProxy stays "true": anything unknown keeps tunnelling,
and only the bundles plus geoip:ru are moved into DirectSites and DirectIp. That
keeps the failure mode safe — an unlisted blocked service still works, it is just
not accelerated.

Geo URLs use the {{GEOIP_URL}} / {{GEOSITE_URL}} / {{LAST_UPDATED}} placeholders,
so an operator-wide profile carries no per-subscriber token. Existing overrides
are backed up before being replaced, and removing the file restores the managed
default.

Verified: bash -n; generator emits 258 direct domains covering ozon.ru,
sberbank.ru, gosuslugi.ru and vk.com; the profile is rejected by
_happ_validate_routing_json while placeholders are unresolved and accepted after
substitution.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg
…fits

The routing profile reaches the client in a response header. The generated
location /sub/ left proxy_buffer_size at the nginx default of 4k, which the
managed profile fits into only because DirectSites is empty.

The generator added in the previous commit emits 258 domains, which lands the
header around 8 KB. nginx then answers 502 and logs 'upstream sent too big
header', so the subscription stops working for every client at once — found
while rolling this out on a live server.

Raises the buffers in both generated /sub/ blocks and documents the constraint,
including the case where an operator puts their own reverse proxy in front.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg
@tony-adamson

Copy link
Copy Markdown
Author

Дополнил PR ещё одним коммитом: при выкатке на живой сервер выяснилось, что генератор ломает подписку.

Профиль уезжает клиенту в заголовке ответа, а в генерируемом location /sub/ proxy_buffer_size оставался дефолтным (4k). Управляемый профиль в него влезает только потому, что DirectSites пустой. Генератор из этого PR выдаёт 258 доменов, заголовок вырастает примерно до 8 КБ, и nginx отвечает 502 с upstream sent too big header — подписка перестаёт работать сразу у всех клиентов.

Поднял буферы в обоих блоках location /sub/ и описал ограничение в документации, включая случай со своим реверс-прокси перед подпиской.


🤖 Generated with Claude Code

https://claude.ai/code/session_0121QZ51XSKaiKcRrtoVQTDg

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant