Мои заметки
Личный блокнот, виден только тебе (привязан к логину). Заметок может быть сколько угодно, каждая сохраняется отдельно.
Генератор: XHTTP + Yandex Cloud CDN
Вводишь домен origin-сервера, IP сервера и технический CDN-домен — получаешь пошагово всё, что вписывать: DNS-записи, сертификаты, nginx, панель Yandex Cloud CDN и конфиг инбаунда в x-ui. Схема проверена живьём (XHTTP packet-up с обходом блокировки POST через переключение метода на OPTIONS + правильный внешний адрес в подписке через externalProxy).
12. Фильтр «только мобильные операторы РФ» (опционально, для аварийных подключений)
Смысл: разрешить доступ к XHTTP-пути только с IP мобильных операторов —
экономит ресурсы сервера, если инбаунд держат как «на крайний случай, когда всё остальное
не работает». Живёт в nginx через geo-директиву по списку подсетей.
⚠ КРИТИЧНО — главная ловушка: Yandex CDN дописывает свой собственный
IP через запятую в заголовок X-Forwarded-For (формат
"IP_клиента, IP_CDN"). Директива geo не умеет парсить список —
пытается сравнить ВСЮ строку как один IP и не матчит НИЧЕГО. Без этого фикса фильтр
блокирует 100% реального трафika через CDN, хотя тесты голым curl с одним IP
(без запятой) будут ошибочно показывать, что всё работает. Обязательно нужен map,
вырезающий только первый IP:
map $http_x_forwarded_for $mobile_check_ip {
default $http_x_forwarded_for;
"~^(?<first_ip>[0-9a-fA-F.:]+)\s*," $first_ip;
}
geo $mobile_check_ip $is_mobile_operator {
default 0;
# ... сюда подсети из скрипта ниже ...
}
В location с XHTTP (перед proxy_pass):
if ($is_mobile_operator = 0) { return 404; }
⚠ Сбор списка подсетей — RIPEstat searchcomplete НЕ годится напрямую: это текстовый автокомплит по короткому имени ASN, а не поиск по организации. У МТС — 23 региональных ASN, у МегаФона — 39, из них searchcomplete по слову «megafon» находит только 1. Из-за этого реальный оператор пользователя может остаться без покрытия (так и было — MF-UGSM-AS AS31224 не находился, пока не перешли на whois).
Правильный способ — обратный поиск по RIPE org-id через whois -i org:
сначала whois -h whois.ripe.net ASxxxx | grep org: у одного известного ASN
оператора, потом whois -h whois.ripe.net -- '-i org ORG-ID' — найдёт ВСЕ
ASN этого юрлица.
⚠ У RIPE whois:43 суточный лимит запросов — банит IP сервера на день
при частом использовании (легко словить во время активной отладки). HTTP API
(stat.ripe.net, searchcomplete/announced-prefixes) — это ОТДЕЛЬНЫЙ сервис
со своим лимитом, обычно не банится вместе с whois:43. Если один сервер забанен —
можно погонять запрос с другого сервера флота (у него свой IP) и скопировать готовый
файл. Скрипт ниже — HTTP-API-версия (не требует whois, работает даже при бане, но
находит меньше региональных ASN, чем полный whois-обход):
#!/usr/bin/env python3
import json, time, urllib.request, urllib.parse
OPERATORS = {
"mts": "MTS PJSC", "megafon": "MegaFon", "vimpelcom": "Vimpelcom",
"beeline": "Beeline", "tele2": "T2 Mobile", "scartel": "Scartel",
"tinkoff": "T-MOB", "sber": "Sberbank-Telecom",
"krymtelecom": "KRYMTELECOM", "volna": "VOLNA", "k-telecom": "K-telekom",
"miranda": "Miranda-Media", "crelcom": "CRELCOM",
}
# ASN, которые не находятся текстовым поиском, но подтверждены вручную
EXTRA_ASNS = {
"31499": "Motiv (Ekaterinburg-2000 LLC)",
"31224": "MegaFon (MF-UGSM-AS)",
"16345": "BEE-AS PJSC Vimpelcom (Beeline, основной ASN)",
}
OUT_PATH = "/etc/nginx/conf.d/mobile_ranges.conf"
def searchcomplete_asns(term):
url = f"https://stat.ripe.net/data/searchcomplete/data.json?resource={urllib.parse.quote(term)}"
with urllib.request.urlopen(url, timeout=20) as r: data = json.load(r)
out = []
for cat in data.get("data", {}).get("categories", []):
if cat["category"] == "ASNs":
for s in cat["suggestions"]:
out.append((s["value"].replace("AS", ""), s["description"]))
return out
def fetch_prefixes(asn):
url = f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}"
with urllib.request.urlopen(url, timeout=20) as r: data = json.load(r)
return [p["prefix"] for p in data.get("data", {}).get("prefixes", []) if "." in p["prefix"]]
def main():
lines = ["map $http_x_forwarded_for $mobile_check_ip {",
" default $http_x_forwarded_for;",
' "~^(?[0-9a-fA-F.:]+)\\s*," $first_ip;', "}", "",
"geo $mobile_check_ip $is_mobile_operator {", " default 0;"]
total = 0; seen = set()
for term, holder in OPERATORS.items():
try: cands = searchcomplete_asns(term)
except Exception as e: print(f"warn {term}: {e}"); continue
matched = [(a,d) for a,d in cands if holder.lower() in d.lower() and a not in seen]
for asn, desc in matched:
seen.add(asn)
try: prefixes = fetch_prefixes(asn)
except Exception as e: print(f"warn AS{asn}: {e}"); continue
lines.append(f" # AS{asn} {desc}")
for p in prefixes: lines.append(f" {p} 1;")
total += len(prefixes); time.sleep(0.2)
for asn, desc in EXTRA_ASNS.items():
if asn in seen: continue
try: prefixes = fetch_prefixes(asn)
except Exception: continue
lines.append(f" # AS{asn} {desc}")
for p in prefixes: lines.append(f" {p} 1;")
total += len(prefixes)
lines.append("}")
open(OUT_PATH, "w").write("\n".join(lines) + "\n")
print(f"written: {total} prefixes, {len(seen)} ASNs")
if __name__ == "__main__":
main()
Установка + крон на еженедельное обновление (подсети операторов иногда меняются):
chmod +x /opt/update_mobile_ranges.py python3 /opt/update_mobile_ranges.py nginx -t && systemctl reload nginx (crontab -l 2>/dev/null; echo '0 4 * * 0 /usr/bin/python3 /opt/update_mobile_ranges.py >> /var/log/mobile_ranges_update.log 2>&1 && systemctl reload nginx') | crontab -
⚠ Проверять фильтр только реальным форматом заголовка (с запятой, как реально шлёт CDN), иначе легко словить ложное «работает»:
curl -s -o /dev/null -w '%{http_code}\n' --resolve ВАШ_CDN_ДОМЕН:8443:127.0.0.1 \
https://ВАШ_CDN_ДОМЕН:8443/api-test/testsession -H 'X-Forwarded-For: 85.140.164.5, 1.2.3.4'
# 400 = дошло до бэкенда (мобильный IP прошёл)
# 404 = заблокировано фильтром (не мобильный ИЛИ подсеть не найдена в списке)
13. Смена XHTTP-пути на живом сервере — алиас со старым путём
Реальные клиенты держат закешированную подписку и НЕ подтягивают новый путь моментально (даже после полного переустановки приложения бывали случаи по 20+ минут без единого нового запроса на актуальный путь). При смене пути — всегда оставлять старый путь рабочим ещё какое-то время через алиас с rewrite (xray сам проверяет путь, не только nginx — простого nginx location с proxy_pass на старом имени НЕДОСТАТОЧНО, нужен именно rewrite на актуальный путь перед proxy_pass):
location /СТАРЫЙ-ПУТЬ {
if ($is_mobile_operator = 0) { return 404; } # если фильтр включён
rewrite ^/СТАРЫЙ-ПУТЬ(.*)$ /НОВЫЙ-ПУТЬ$1 break;
proxy_pass http://127.0.0.1:ПОРТ;
proxy_method $xhttp_proxy_method;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass_request_headers on;
proxy_buffering off;
proxy_request_buffering off;
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
14. Паддинг трафика — баланс маскировки и скорости
xPaddingBytes в xhttpSettings — мусорные байты поверх
реальных данных, маскируют характерный размер XHTTP-фреймов. Компромисс: чем больше
диапазон — тем лучше маскировка, но больше задержка/расход трафика. У некоторых
клиентских приложений (sing-box-based, включая Incy) есть встроенный тест соединения
(URL-test) с достаточно жёстким таймаутом — при агрессивном паддинге (100-1000)
этот внутренний тест может показывать «недоступно», даже когда реальный трафик работает
нормально. Рабочий компромисс: 50-200 — заметно легче, маскировка размера
пакетов остаётся. Если у пользователя приложение на sing-box — сначала проверить, нет ли
в самом приложении настройки таймаута/URL проверки соединения (обычно в настройках
группы/профиля прокси) — это чище, чем трогать сервер.
"xhttpSettings": {"path": "/...", "uplinkHTTPMethod": "OPTIONS", "xPaddingBytes": "50-200"}
15. TLS fingerprint в подписке (externalProxy.fingerprint)
Влияет только на то, что клиентское приложение ЧИТАЕТ из подписки —
сервер сам никогда не выступает TLS-клиентом, так что это чистая рекомендация клиенту,
которую он может как угодно (не) учитывать. Варианты: chrome — фиксированная,
узнаваемая сигнатура; random — каждое подключение выбирает случайный, но
НАСТОЯЩИЙ браузерный профиль (включая iOS/Safari) из пула; randomized —
синтетическая, ни на что не похожая последовательность (может выделяться сильнее, чем
обычный browser fingerprint, поэтому random обычно предпочтительнее для
маскировки под обычных пользователей).
16. Блокировка CDN-ресурса целиком аккаунтом Yandex — полная пересборка
⚠ Как это выглядит: в панели Cloud CDN у ресурса статус
Blocked, при клике — «CDN-ресурс заблокирован. Для получения дополнительной
информации и решения проблемы обратиться в службу поддержки», без причины. Признак —
резко высокая доля ответов 4xx в «Метриках за 30 дней» относительно общего числа запросов.
Блокируется ИМЕННО РЕСУРС (и его технический yacdn.* домен вместе с ним) — то есть чинить
старый ресурс бессмысленно, нужен новый ресурс + новый CDN-домен. Понять, аккаунтный это
бан или ресурсный, можно только через тикет в поддержку — на практике проще и быстрее
просто пересобрать на новом домене и проверить.
Важно при пересборке: никогда не использовать в новых CDN-доменах
исходные хостнеймы серверов (например ya2ru.maxnode.ru) — они уже
«засвечены» в заблокированном ресурсе. Заводить ДВА новых поддомена на каждый узел:
технический (origin, его видит только CDN изнутри) и публичный (CDN-домен, его видят
клиенты) — оба без намёка на реальное имя сервера.
svc-X.ваш-домен → A-запись → IP сервера (технический/origin)
net-X.ваш-домен → A-запись → IP сервера, ВРЕМЕННО (публичный, до создания CDN-ресурса)
→ после создания ресурса замена на CNAME, который выдаст Yandex
(DNS only / серое облако — Cloudflare-проксирование поверх сломает CDN)
Дальше — сертификат и nginx-vhost по generator-схеме выше (origin=svc-X, cdn=net-X), плюс SNI-запись в stream{} map. Если порт 80 закрыт файрволом (см. карточку про hardening) — временно открыть на время certbot, закрыть обратно сразу после:
ufw allow 80/tcp certbot certonly --nginx -d svc-X.домен -d net-X.домен --non-interactive --agree-tos -m EMAIL ufw delete allow 80/tcp
В форме создания ресурса в панели — секьюрити-профиль TLS ставить «Защищённый (TLSv1.2+ с поддержкой PFS и AEAD)», не «Усиленный» (TLS1.3-only сам по себе необычен для рядового сайта и может быть отличительным признаком, а устаревшие профили держат TLS1.0/1.1, что тоже нетипично для 2026 года). «Перенаправление запросов» (rewrite) и «Следование перенаправлениям от источника» — оба выключить: origin никогда не отдаёт 3xx на XHTTP-путь, а вмешательство CDN в момент открытого потока рвёт сессию.
⚠ Второй TLS-хоп (CDN → origin) НЕ виден клиентскому DPI/сетевой инспекции в принципе — он целиком происходит внутри сети облака. Пытаться «убрать терминацию TLS на CDN» ради анти-детекта — бессмысленно и даже вредно: теряется маскировка под шеринг с обычным CDN-трафиком в обмен на посадку на выделенный IP, который легче фingerprint-ить как принадлежащий именно вам.
Проверка нового ресурса ДО передачи в прод (сертификат подключается не сразу, до 15 минут):
# с самого origin-сервера, не с произвольной рабочей машины — # у некоторых окружений может страдать исходящий 443 к российским IP, # это НЕ проблема сервера curl -sSk -D - -o /dev/null https://net-X.домен/cdn-check -v 2>&1 | grep -iE 'subject|issuer|< HTTP' # ожидается: issuer Let's Encrypt, subject = ваш CDN-домен, HTTP/2 204 # (если видите issuer GlobalSign / subject *.yccdn.cloud.yandex.net — # сертификат ещё не подключился, подождать и повторить)
17. Смена пути одновременно со сменой домена — реальный пример
Заодно с переездом на новый CDN-домен путь тоже стоит сменить на что-то
тематическое под контент заглушки, а не technical-looking /api-test — само
такое имя выглядит как тестовый артефакт. Пример (заглушка — форма логина роутера):
"xhttpSettings": {"path": "/cgi-bin/luci/api/auth", "uplinkHTTPMethod": "OPTIONS", "xPaddingBytes": "100-500"}
Старый путь оставлен алиасом (карточка 13) — обязательно, без этого действующие подписки ловят 404 сразу после смены.
18. server_tokens off — минимальный обязательный хардненинг nginx
Версия nginx в заголовках ответа — бесплатная информация для любого
автоматического сканера. Добавить один раз в http {} глобально (обычно уже
есть закомментированной строкой в дефолтном nginx.conf — тогда просто раскомментировать):
server_tokens off;
19. JSON эталонных инбаундов на master — для ручного повтора
На master (x-ui, отдельно от обоих нод) инбаунды-«эталоны» синхронизируются
с реальным состоянием нод сами — трогать их отдельно после смены домена/пути на нодах
обычно не требуется. Ниже — актуальный на момент последней пересборки JSON
stream_settings обоих whitelist-инбаундов master (id 51 и 53), чтобы можно
было руками пересоздать с нуля через SQL или UI, если понадобится:
-- id 51, порт 8003 (узел B)
{"network":"xhttp","security":"none","xhttpSettings":{"path":"/cgi-bin/luci/api/auth","uplinkHTTPMethod":"OPTIONS","xPaddingBytes":"100-500"},"externalProxy":[{"forceTls":"tls","dest":"net-b.nevbloke.lol","port":443,"remark":"","sni":"net-b.nevbloke.lol","fingerprint":"random","alpn":["h2"]}]}
-- id 53, порт 8005 (узел A)
{"network":"xhttp","security":"none","xhttpSettings":{"path":"/cgi-bin/luci/api/auth","uplinkHTTPMethod":"OPTIONS","xPaddingBytes":"100-500"},"externalProxy":[{"forceTls":"tls","dest":"net-a.nevbloke.lol","port":443,"remark":"","sni":"net-a.nevbloke.lol","fingerprint":"random","alpn":["h2"]}]}
-- применить руками на master, если разошлось с нодами:
UPDATE inbounds SET stream_settings='...' WHERE id=51;
UPDATE inbounds SET stream_settings='...' WHERE id=53;
20. «Подключено, но ничего не грузится» — базовое поле clients.flow, НЕ flow_override
⚠ Отдельный от уже известного flow-guard бага случай. Симптом: клиентское приложение показывает статус «подключено» (зелёный), TLS/транспорт реально устанавливается, но сквозного трафика нет вообще — ни через один сайт. При этом сервер массово и успешно обслуживает других клиентов на том же инбаунде (видно по логам) — то есть сеть, домен, сертификат, CDN, мобильный фильтр ни при чём.
Причина: в таблице clients есть своё собственное поле
flow (не путать с client_inbounds.flow_override, который чистит
flow-guard) — оно ОДНО на клиента, общее для всех инбаундов, к которым он привязан. Если
клиент когда-либо был создан/отредактирован с проставленным xtls-rprx-vision
(типичный дефолт панели при добавлении клиента), это значение остаётся в базе даже если
клиент используется только на XHTTP-инбаунде, где vision в принципе не поддерживается.
flow-guard эту базовую колонку не трогает — только override. Обнаружено массово: 215 клиентов
на одном сервере, 449 — на другом, все с flow='xtls-rprx-vision' в базе.
Диагностика — сверить базу с реально скомпилированным конфигом xray (не x-ui.db, а то, что xray ФАКТИЧЕСКИ запустил):
sqlite3 /etc/x-ui/x-ui.db "SELECT email, flow FROM clients WHERE flow='xtls-rprx-vision';"
python3 -c "
import json
d = json.load(open('/usr/local/x-ui/bin/config.json'))
for ib in d['inbounds']:
if ib.get('port') == ВАШ_ПОРТ:
for c in ib['settings']['clients']:
print(c) # если у XHTTP-инбаунда в выводе есть 'flow' — вот и причина
"
Фикс — если на сервере НЕТ ни одного инбаунда, где vision реально нужен (raw TCP+TLS/REALITY vless — не CDN/XHTTP и не hysteria), можно смело обнулить поле у всех клиентов сразу, это ничего не сломает:
sqlite3 /etc/x-ui/x-ui.db "UPDATE clients SET flow='' WHERE flow='xtls-rprx-vision';" systemctl restart x-ui
⚠ Применить на ВСЕХ трёх местах сразу (обе ноды + master) — иначе при следующей синхронизации/пересоздании клиента через master старое значение может вернуться.
21. Hysteria2 без домена — самоподписанный сертификат на IP + pinned-отпечаток
Hysteria2 работает поверх QUIC/UDP — через Yandex Cloud CDN не проходит в принципе (CDN не поддерживает UDP), так что домен ему нужен ТОЛЬКО ради валидного TLS-сертификата, а не ради маскировки/CDN-фронтинга. Чтобы убрать из конфига упоминание реального хостнейма сервера (ya2ru.maxnode.ru / 4files.1eks.ru) — сделали самоподписанный сертификат прямо на IP и pinned SHA256-отпечаток вместо доверия обычной CA-цепочке:
mkdir -p /etc/hysteria-selfsigned openssl req -x509 -newkey rsa:2048 -keyout /etc/hysteria-selfsigned/key.pem \ -out /etc/hysteria-selfsigned/cert.pem -days 3650 -nodes -subj '/CN=ВАШ_IP' \ -addext 'subjectAltName=IP:ВАШ_IP' # отпечаток для pinnedPeerCertSha256 (base64 SHA256 от DER-сертификата): openssl x509 -in /etc/hysteria-selfsigned/cert.pem -outform der | openssl dgst -sha256 -binary | base64
В tlsSettings инбаунда: serverName = сам IP,
certificates[0].certificateFile/keyFile → пути к новым файлам,
settings.pinnedPeerCertSha256 → полученный отпечаток (массив из одной строки).
⚠ Автопродления НЕТ — это не Let's Encrypt, никакого certbot/таймера.
Специально выпущен на 10 лет (-days 3650), чтобы вопрос ротации не
всплывал десятилетие. Важно: если когда-нибудь всё же перевыпустить этот сертификат вручную —
отпечаток изменится, и ВСЕ существующие Hysteria-подписки одномоментно перестанут проходить
pinned-проверку, пока клиенты не обновят конфиг. Трогать этот файл — редкое осознанное
действие, не рутинная операция.
22. Чек-лист: CDN-ресурс снова заблокирован
- Проверить в панели Yandex Cloud CDN — статус ресурса
Blocked? (клик на ресурс → баннер с причиной, обычно без деталей) - Придумать новый CDN-домен — новый поддомен, никогда не переиспользовать старый и не упоминать в нём реальный хостнейм сервера
- Cloudflare — добавить временную A-запись нового домена на IP сервера (DNS only, серое облако)
- Дать знать, что нужен новый CDN-ресурс на этот домен — origin разворачивается (сертификат, nginx-vhost, x-ui) со стороны сервера
- Certificate Manager в Yandex Cloud — выпустить LE-сертификат на новый CDN-домен
- Создать CDN-ресурс по форме (карточка 16/генератор выше): профиль TLS «Защищённый (TLSv1.2+ с PFS и AEAD)», методы GET/HEAD/OPTIONS, всё остальное (кеш/сжатие/редиректы/ CORS) — выключено
- Cloudflare — заменить временную A-запись на CNAME, который выдаст Yandex после создания ресурса
- Проверить туннель через настоящий CDN, обновить x-ui (dest/sni) на новый домен — только после этого считать готовым
- Старый (заблокированный) ресурс удалить из панели Yandex Cloud
Уже отработано дважды за один день — с новым доменом весь цикл занимает порядка 20-30 минут, а не часы, как в первый раз.
23. Секретный заголовок к origin — скрыть реальный IP сервера от прямого обнаружения
Если кто-то узнает реальный IP origin-сервера (историческая DNS-запись, SAN сертификата, сканер типа Censys/Shodan) и постучится напрямую в обход CDN — увидит ровно ту же инфраструктуру без всякой маскировки: тот же decoy, тот же XHTTP-путь, реагирует на мобильный фильтр так же, как настоящий эндпоинт. Официальный механизм Яндекса для origin-защиты (аналог Cloudflare Authenticated Origin Pulls) — секретный заголовок, который CDN добавляет к КАЖДОМУ запросу к источнику, а origin проверяет его наличие.
В панели Yandex Cloud CDN: ресурс → вкладка «HTTP-заголовки и методы» → блок «Заголовки запроса к источнику» → добавить заголовок (имя обязательно в нижнем регистре):
x-cdn-secret: СЛУЧАЙНЫЙ_16_БАЙТ_HEX (openssl rand -hex 16)
В nginx-vhost (карточка 4), сразу после client_max_body_size,
на уровне всего server{} — тогда закрывается не только XHTTP-путь, но и decoy-страница:
if ($http_x_cdn_secret != "СЕКРЕТ") { return 404; }
⚠ Порядок обязателен, иначе обрыв сервиса для всех живых клиентов: 1) прописать заголовок в консоли и сохранить; 2) подождать и проверить, что он реально доходит (см. ниже); 3) только потом включать блокирующую проверку на nginx. Настройка CDN-ресурса раскатывается на edge-ноды не мгновенно — на разных ресурсах в одном и том же прогоне заняло от секунд до нескольких минут.
# временно добавить поле в лог, БЕЗ блокировки, и последить за живым трафиком: # log_format ... secret="$http_x_cdn_secret" ... tail -f /var/log/nginx/cdn_debug.log | grep -o 'secret="[^"]*"' # secret="-" на реальных запросах = ещё не раскатилось, рано включать блокировку # secret="ВАШ_СЕКРЕТ" на реальных запросах = можно включать if-проверку выше
24. uplinkHTTPMethod на СЕРВЕРНОЙ стороне — почему это правильнее ручных ссылок
Уточнение к карточке 7: x-ui не «обрезает» специально нестандартные поля
xhttpSettings.extra — она зеркалит в генерируемую ссылку (и в свою собственную подписку,
и в агрегированную подписку master, если этот инбаунд туда синхронизирован как узел) ровно
то, что реально прописано в конфиге инбаунда на сервере. Если uplinkHTTPMethod
стоит только в ручном клиентском JSON — в подписку он не попадёт никогда, панели просто
неоткуда его взять. Решение — прописать это же значение прямо в серверном
xhttpSettings (xray на приёмной стороне поле просто игнорирует, не мешает
работе, ошибок не вызывает):
{
"path": "/ваш-путь",
"uplinkHTTPMethod": "OPTIONS",
"xPaddingBytes": "50-200"
}
Проверено: подписка на master обновляется МГНОВЕННО, без задержки синхронизации узла — правка на ноде сразу видна в подписке любого клиента, привязанного к этому инбаунду через master. Отдельный генератор персональных ссылок (если он у вас есть, как обходной путь до этого фикса) после этого становится необязательным.
25. Порядок подключений в подписке master — subSortIndex
Управляет позицией пункта в общей (агрегированной) подписке клиента.
Если у всех инбаундов стоит одно и то же значение (часто по умолчанию у всех 1)
— реальный порядок сортировки внутри такой «привязанной» группы определяется id
инбаунда по возрастанию (порядок создания). Чтобы вставить новый пункт МЕЖДУ двумя
существующими — не меняют id (нельзя), а поднимают subSortIndex у всех пунктов,
которые должны остаться ПОСЛЕ вставляемого, на следующее число (например с 1 на 2):
# получить id и текущий subSortIndex всех инбаундов на master:
curl -s -k -b cookie.txt -X GET "https://master:ПАНЕЛЬ_ПОРТ/БАЗОВЫЙ_ПУТЬ/panel/api/inbounds/list" \
-H "X-CSRF-Token: $CSRF" | python3 -c "
import json,sys
d = json.load(sys.stdin)
for ib in d['obj']:
print(ib['id'], ib.get('subSortIndex'), ib['remark'])
"
# затем POST на /panel/api/inbounds/update/{id} с тем же телом инбаунда,
# но с добавленным/изменённым полем "subSortIndex": 2 — для каждого пункта,
# который должен сортироваться ПОСЛЕ новых. Пункты, которые остаются на 1,
# упорядочиваются между собой по id — то есть свежедобавленные (id больше)
# окажутся в конце своей группы, сразу перед тем, что подняли на 2.
26. Авточекер мобильных IP — самообучение белого списка на живом трафике
Вместо разового прогона скрипта из карточки 12 — фоновый чекер, который
смотрит IP, отклонённые мобильным фильтром прямо сейчас (is_mobile=0 в
cdn_debug.log), определяет владельца через RIPEstat и, если похоже на мобильного оператора,
сам добавляет анонсированный префикс в белый список. Полезно первые 1-2 суток после
запуска нового узла, пока реальные клиенты «протаптывают» список операторов сами.
⚠ Главная ловушка — название оператора может обманывать. Живой
пример: KAMENSKTEL-AS ... Radiotelephone — несмотря на слово «radiotelephone»
в названии, это оператор ПРОВОДНОГО интернета, не мобильный, и был первоначально ошибочно
добавлен по этому ключевому слову (десяток подсетей). Обнаружено и откачено пользователем
в течение минуты — но это показывает: ключевые слова должны матчить конкретное известное
юрлицо/бренд оператора, а не общие термины вроде «radio», «mobile», «cellular» —
слишком много проводных операторов исторически называют себя похоже. При любом сомнении
в новом holder-имени — не добавлять автоматически, только логировать и спросить.
#!/usr/bin/env python3
"""Смотрит новые is_mobile=0 записи в cdn_debug.log, классифицирует владельца IP
через RIPEstat и добавляет префикс в mobile_ranges.conf, если это известный
мобильный оператор. НЕ добавляет по общим словам — только по названиям/брендам
конкретных операторов, явно перечисленным ниже."""
import json, re, time, urllib.request, subprocess
CDN_DEBUG_LOG = "/var/log/nginx/cdn_debug.log"
MOBILE_CONF = "/etc/nginx/conf.d/mobile_ranges.conf"
STATE_FILE = "/opt/mobile_checker_state.json"
LOG_FILE = "/var/log/mobile_ip_checker.log"
MOBILE_KEYWORDS = [
"mts", "megafon", "mf-", "sonicduo", "peterstar",
"vimpelcom", "bee-as", "beeline", "sovam", "corbina",
"tele2", "t2 mobile", "t2-", "yota", "scartel",
"motiv", "tinkoff", "t-mob", "sberbank-telecom",
"krymtelecom", "volna", "k-telecom", "miranda-media", "crelcom",
]
# Явные ложные срабатывания по общим словам — держать в исключениях навсегда:
EXCLUDE_KEYWORDS = ["rostelecom", "er-telecom", "ertelecom", "domru", "dom.ru",
"kamensktel", "radiotelephone"]
def log(msg):
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}"
print(line)
with open(LOG_FILE, "a") as f: f.write(line + "\n")
def load_state():
try: return json.load(open(STATE_FILE))
except Exception: return {"last_line_count": 0, "checked_ips": {}, "added_prefixes": []}
def save_state(state):
json.dump(state, open(STATE_FILE, "w"), ensure_ascii=False, indent=2)
def get_rejected_ips(since_line):
lines = open(CDN_DEBUG_LOG).readlines()
ips = set()
for line in lines[since_line:]:
if "is_mobile=0" not in line: continue
m = re.search(r'mobile_ip="([0-9.]+)"', line)
if m and m.group(1): ips.add(m.group(1))
return ips, len(lines)
def ripestat_lookup(ip):
url = f"https://stat.ripe.net/data/prefix-overview/data.json?resource={ip}"
try:
with urllib.request.urlopen(url, timeout=15) as r: data = json.load(r)["data"]
asns = data.get("asns", [])
if not asns: return None
return {"asn": asns[0].get("asn"), "holder": asns[0].get("holder", ""),
"prefix": data.get("resource")}
except Exception as e:
log(f"RIPEstat lookup failed for {ip}: {e}"); return None
def classify(holder):
h = holder.lower()
for kw in EXCLUDE_KEYWORDS:
if kw in h: return "broadband"
for kw in MOBILE_KEYWORDS:
if kw in h: return "mobile"
return "unknown"
def add_prefix_to_conf(prefix, holder):
content = open(MOBILE_CONF).read()
if prefix in content: return False
marker = "geo $mobile_check_ip $is_mobile_operator {"
idx = content.find(marker)
if idx == -1: log("ERROR: geo block marker not found"); return False
pos = idx + len(marker) + 1
content = content[:pos] + f" # auto-checker: {holder}\n {prefix} 1;\n" + content[pos:]
open(MOBILE_CONF, "w").write(content)
return True
def reload_nginx():
subprocess.run(["nginx", "-t"], check=True, capture_output=True)
subprocess.run(["systemctl", "reload", "nginx"], check=True)
def main():
state = load_state()
ips, new_line_count = get_rejected_ips(state["last_line_count"])
new_ips = [ip for ip in ips if ip not in state["checked_ips"]]
log(f"checked so far: {len(state['checked_ips'])}, new to check: {len(new_ips)}")
added_any = False
for ip in new_ips:
info = ripestat_lookup(ip); time.sleep(0.3)
if not info:
state["checked_ips"][ip] = {"result": "lookup_failed"}; continue
cls = classify(info["holder"])
state["checked_ips"][ip] = {"asn": info["asn"], "holder": info["holder"],
"prefix": info["prefix"], "classified": cls}
if cls == "mobile" and info["prefix"]:
if add_prefix_to_conf(info["prefix"], info["holder"]):
log(f"ADDED {info['prefix']} ({info['holder']}, AS{info['asn']}) from ip {ip}")
state["added_prefixes"].append(info["prefix"]); added_any = True
else:
log(f"already present: {info['prefix']} ({info['holder']})")
elif cls == "broadband":
log(f"skip (broadband): {ip} -> {info['holder']}")
else:
log(f"skip (unknown/unmatched): {ip} -> {info['holder']} AS{info['asn']}")
state["last_line_count"] = new_line_count
save_state(state)
if added_any:
reload_nginx(); log("nginx reloaded")
if __name__ == "__main__":
main()
Установка — раз в 15 минут по cron, с автоснятием через 48 часов
(свободный at не всегда стоит на сервере — делаем через одноразовую
cron-запись на конкретную дату/время):
chmod +x /opt/mobile_ip_checker.py python3 /opt/mobile_ip_checker.py # первый прогон, обрабатывает весь текущий лог (crontab -l 2>/dev/null; echo '*/15 * * * * /usr/bin/python3 /opt/mobile_ip_checker.py >> /var/log/mobile_ip_checker_cron.log 2>&1') | crontab - # автоснятие через 48 часов (подставить дату из `date -u -d '+48 hours' '+%M %H %d %m *'`): (crontab -l 2>/dev/null; echo 'MM HH DD MM * crontab -l | grep -v mobile_ip_checker | crontab -') | crontab -
После снятия — просмотреть /var/log/mobile_ip_checker.log,
строки skip (unknown/unmatched) — кандидаты, которые чекер сознательно НЕ
добавил сам; решение по ним — вручную, после проверки holder-имени.
27. Reality несовместим с CDN на одном хопе — не тратить время повторно
Официально подтверждено самим механизмом Reality: CDN обязан термировать TLS сам, чтобы маршрутизировать по домену — а Reality требует нетронутый ClientHello с SNI чужого сайта, иначе не может провести проверку/подмену сертификата. Комбинация «Reality через тот же CDN-домен, что и XHTTP» технически невозможна в принципе, вне зависимости от конфигурации. Если нужен Reality — только на отдельном порту/SNI, минуя CDN полностью (прямое подключение клиента к origin).
⚠ Отдельно от несовместимости с CDN: на xray-core 26.7.28 Reality не проходит рукопожатие вообще, даже в полностью изолированном тесте (голый vless+tcp+reality, без nginx, без CDN, клиент и сервер на одной машине, ключи/shortId/версия клиента — всё подтверждено верным). Похоже на баг конкретно этой сборки — если Reality нужен, сначала проверить на заведомо рабочей версии (например 26.5.9, см. карточку 8), не тратить время на отладку конфигурации.