РоутерыИстория прошивокАктивации программМануалыНастройки

Мои заметки

Личный блокнот, виден только тебе (привязан к логину). Заметок может быть сколько угодно, каждая сохраняется отдельно.

Генератор: 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-ресурс снова заблокирован

  1. Проверить в панели Yandex Cloud CDN — статус ресурса Blocked? (клик на ресурс → баннер с причиной, обычно без деталей)
  2. Придумать новый CDN-домен — новый поддомен, никогда не переиспользовать старый и не упоминать в нём реальный хостнейм сервера
  3. Cloudflare — добавить временную A-запись нового домена на IP сервера (DNS only, серое облако)
  4. Дать знать, что нужен новый CDN-ресурс на этот домен — origin разворачивается (сертификат, nginx-vhost, x-ui) со стороны сервера
  5. Certificate Manager в Yandex Cloud — выпустить LE-сертификат на новый CDN-домен
  6. Создать CDN-ресурс по форме (карточка 16/генератор выше): профиль TLS «Защищённый (TLSv1.2+ с PFS и AEAD)», методы GET/HEAD/OPTIONS, всё остальное (кеш/сжатие/редиректы/ CORS) — выключено
  7. Cloudflare — заменить временную A-запись на CNAME, который выдаст Yandex после создания ресурса
  8. Проверить туннель через настоящий CDN, обновить x-ui (dest/sni) на новый домен — только после этого считать готовым
  9. Старый (заблокированный) ресурс удалить из панели 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), не тратить время на отладку конфигурации.