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

Мои заметки

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

Флот роутеров: обратный туннель и sing-box — живые баги (2026-09-16)

Два независимых роутера («мам+ksu», лайт-прошивка, и «Пустоваров», обычный форкоп) одновременно выглядели "не поднимается sing-box". Причины оказались разными и не связаны друг с другом — но обе стоит проверять в первую очередь при похожих жалобах.

1. sshd MaxStartups глушит переподключения всего флота

Все роутеры (и форкоп, и лайт-архитектура) держат обратный SSH-туннель на сервере через одного системного пользователя blackbox. При >100 одновременно живых туннелей и периодических волнах переподключений (крон у многих роутеров срабатывает синхронно) sshd начинает молча дропать часть НОВЫХ подключений ещё до стадии аутентификации — это штатное анти-DoS поведение MaxStartups, по умолчанию 10:30:100 (начинает вероятностно дропать уже с 10 одновременных неаутентифицированных соединений). Живой симптом: роутер числится offline, процесс туннеля на роутере живой и honestly пытается подключиться каждые ~30с, но ни одного нового коннекта не долетает до auth-стадии sshd (видно по journalctl -u ssh | grep <IP роутера> — тишина, хотя journalctl -u ssh | grep MaxStartups в это же время показывает "beginning MaxStartups throttling" / "drop connection ... past MaxStartups").

# /etc/ssh/sshd_config на 31.77.144.137
MaxStartups 30:30:300
# sshd -t && systemctl reload ssh   (reload, НЕ restart — не рвёт уже живые туннели)

Если проблема повторится при росте флота — поднимать порог ещё, ориентируясь на ps aux | grep 'sshd: blackbox' | wc -l (делить на 2, т.к. на каждое соединение по одному privsep-процессу root+blackbox).

2. Лайт-прошивка: dbclient зависает НАВСЕГДА при отказе проброса порта

В usr/bin/revtunnel-rtportal.sh (генерируется lib/liteFirmwareBuild.js на портале) — если сервер отклоняет -R (например "Remote TCP forward request failed", порт ещё занят зависшей старой сессией или временно попал под MaxStartups), dbclient это НЕ считает фатальной ошибкой сессии и не завершается — виснет открытой SSH-сессией без единого форварда. Внешний while true; ... wait "$CHILD_PID" в скрипте ждёт вечно, ничего не переподключая — роутер потом ЧАСАМИ числится offline на портале, хотя процесс "жив" и sing-box у него внутри работает нормально.

Фикс (уже в шаблоне liteFirmwareBuild.js, попадёт во все новые лайт-сборки): вывод dbclient пишется в файл, первые ~20с скрипт опрашивает файл на фразу "forward request failed" и сам убивает процесс, если видит её — это гарантирует честный ретрай через 30с вместо бесконечного зависания. Плюс добавлены -K 20 -I 60 (keepalive/idle-timeout) на случай мёртвого TCP без явной ошибки форварда — тот же класс проблемы, что и у стандартного форкоп-пакета sshtunnel: реверс-туннель сам не лечится при обрыве, нужен либо явный kill процесса, либо самодиагностика внутри скрипта.

Как чинить на уже прошитом роутере (без переприлива): если обратный туннель на портале мёртв, а WAN у роутера свой белый IP — проверить, нет ли уже настроенного проброса на LAN:22 (у "мам+ksu" был WAN:79 → LAN:22, отдельно от туннеля) и зайти напрямую, либо через ubus-over-HTTP на LuCI (см. ниже), если веб-порт проброшен, а SSH — нет.

3. Ubus-over-HTTP как аварийный доступ без SSH

Если проброшен только веб-порт LuCI (не SSH), можно выполнять операции БЕЗ шелла через /ubus JSON-RPC той же LuCI: логин через session.login (username/password root), затем, например, rc.init с {"name":"sing-box","action":"restart"} — это ровно тот же механизм, что дёргает страница LuCI "Автозагрузка/Startup". Список доступных объектов/ACL приходит прямо в ответе на login (поле acls). Не всё разрешено — например, чтение произвольных файлов через ubus call file read ограничено фиксированным allowlist путей (используется тем же движком, что и cgi-io upload/download в LuCI), поэтому логи (/tmp/*.log, logread) через ubus не почитать — тут может выручить логин через обычную HTML-форму LuCI (POST /cgi-bin/luci/ с luci_username/luci_password, куки sysauth_http) и обращение к статус-страницам LuCI напрямую (путь зависит от набора пакетов в конкретной сборке — на минимальной лайт-прошивке стандартный /admin/status/syslog может отсутствовать, если не собран соответствующий luci-mod).

4. sing-box: community rule-set "курица и яйцо" при холодном старте

Форкоп генерирует правила (russia_inside, discord, youtube, github и т.д.) через ensure_community_ruleset() в /usr/lib/forkop/singbox/generator.uc: если на роутере уже ЛЕЖИТ локальный файл /etc/sing-box/rulesets/<имя>.srs — используется он (type: local, без сети). Если файла нет — генератор ставит type: remote с download_detour через VPN-outbound. Если конкретно у роутера какие-то из этих локальных файлов не были засеяны при сборке прошивки (напр. github/youtube/twitter.srs) — при СТАРТЕ sing-box пытается их скачать ЧЕРЕЗ ЕЩЁ НЕ ПОДНЯТЫЙ VPN-outbound → скачивание виснет/рвётся ("io: read/write on closed pipe") → sing-box падает с FATAL → форкоп ретраит старт раз в ~1 мин НАВСЕГДА, не поднимая VPN никогда. Прямой curl с роутера при этом отлично качает те же URL — потому что идёт напрямую, а не через ещё-не-существующий VPN outbound.

# Диагностика: каких локальных .srs не хватает
ls /etc/sing-box/rulesets/
# Сверить со списком COMMUNITY_SERVICES в /usr/lib/forkop/singbox/rulesets.uc

# Фикс: скопировать недостающие файлы с зеркала портала (то же зеркало,
# что источник для всех сборок — /opt/rt-portal/public/rulesets/*.srs,
# обновляется рефреш-кроном; см. lib/rulesetMirror.js)
scp /opt/rt-portal/public/rulesets/{github,youtube,twitter}.srs \
    root@<router-tunnel-port>:/etc/sing-box/rulesets/
forkop reload   # НЕ restart — reload сам подхватит локальные файлы

Если баг повторится на другом роутере — сверить прошивку: значит на момент сборки её кухня/деревопрофиль не засеивал этот конкретный .srs в files/etc/sing-box/rulesets/. Стоит проверить весь актуальный список COMMUNITY_SERVICES при следующей ревизии кухни, чтобы не ловить это по одному роутеру за раз.

5. dbclient -I (idle timeout) рвёт ЖИВОЙ туннель каждые ~1-7 мин

Само-нанесённый регресс при попытке защититься от п.2 выше: добавил -I 60 (idle timeout) к dbclient "на всякий случай" — а чистый port-forward без активных подключений через него это и есть простой с точки зрения dbclient (-N, никаких данных по каналу), так что -I рвал полностью ЗДОРОВУЮ сессию сам, каждые примерно 1-7 минут (зависело от того, шёл ли в этот момент трафик через форвард). Externally выглядело как "туннель то есть, то нет" — веб-интерфейс/SSH то грузится, то нет, без видимой причины. Фикс — просто убрать -I, оставить только -K 20 (keepalive-пинг, НЕ считает сессию простоем, безопасен для чистого форварда). Если снова захочется добавить какой-то timeout к реверс-туннелю — сначала перечитать этот пункт.

6. Аварийный доступ без рабочего обратного туннеля вообще

Если и обратный SSH-туннель мёртв, и веб-порт через него не грузится — проверить, нет ли у роутера ОТДЕЛЬНОГО проброса с белого IP прямо на LAN (не через туннель): uci show firewall / ubus uci.get {"config":"firewall"}, искать type: redirect с src: wan. У "мам+ksu" был WAN:79 → LAN:22 отдельно от туннельных портов — спасло, когда сам туннель не поднимался часами.

7. "sing-box version" на sing-box-tiny — НЕ дешёвая команда

Живой инцидент на "planeta kupi": ручной вызов sing-box version для диагностики (просто чтобы узнать билд) на самом деле запустил ПОЛНОЦЕННЫЙ процесс на ~550МБ (столько же, сколько занимает реально работающий инстанс) в состоянии R (крутится, ест CPU) — на маленьком одноядерном MIPS-роутере это на несколько минут положило вообще ВСЁ, включая тривиальные команды типа uptime. Не запускать sing-box version/любые прямые вызовы бинаря на живых лайт-роутерах просто "посмотреть" — только /etc/init.d/sing-box status (не трогает сам бинарь, просто проверяет pid) или локальный Clash API (см. п. 8 ниже). Если словили зависание — искать вторую копию процесса ps w | grep sing-box и kill -9 её, основной рабочий инстанс (со значком -D /usr/share/sing-box) не трогать.

8. Автообновляющийся статус VPN на портале (форкоп + лайт)

Просьба пользователя: видеть с портала, работает ли VPN и через какой сервер, само собой, плюс недавнюю историю переключений. Реализовано на Clash-совместимом API самого sing-box (experimental.clash_api) — форкоп исторически уже биндит его на LAN-адресе роутера 192.168.1.1:9090, лайт получил его в этом же цикле работ на loopback 127.0.0.1:9090 (см. liteFirmwareBuild.js — попадёт во все новые лайт-сборки). Фоновая служба lib/vpnStatusDaemon.js (сервер, раз в ~3 мин, пароль root — общий флотский дефолт, тот же, что уже использует healthDaemon) SSH'ится на каждый роутер и спрашивает curl http://<127.0.0.1|192.168.1.1>:9090/proxies/... — результат пишется в таблицу vpn_status_log (новая строка только при реальном изменении, не на каждый опрос — иначе таблица распухла бы). В routers.html — авто-обновляемый бейдж 🟢/🟡/⚪ + активный сервер + ссылка "история". У части СТАРЫХ форкоп-сборок и лайт-роутеров, собранных до этой правки, clash_api может быть не включён — тогда бейдж покажет просто "работает/не работает" без имени сервера, это ожидаемо, не баг.

9. CSS: кнопки лайт-роутеров вылезали за границы ячейки таблицы

.manage-cell { display: grid } — у grid-элементов по умолчанию min-width: auto, они НЕ сжимаются ниже собственного содержимого, только распирают колонку под самый длинный текст. У лайт-роутеров подписи кнопок заметно длиннее ("📡 Статус VPN (LITE, вручную)" и т.п.), чем у форкоп-роутеров — без min-width: 0 это было незаметно у форкопа и явно вылезало за границы ячейки у лайт. Фикс — .manage-cell button, .manage-cell > div { min-width: 0; white-space: normal; } в style.css: кнопки теперь сжимаются до колонки и переносят длинный текст на вторую строку вместо распирания макета. Общее правило на будущее: любой display: grid/flex контейнер с непредсказуемо длинным содержимым детей — сразу добавлять min-width: 0 на дети, не дожидаться живой жалобы "вылезает только у X".

Генератор: 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), не тратить время на отладку конфигурации.

28. ⚠ КРИТИЧНО: любой периодический restart exit-xray убивает ВСЕ активные XHTTP-сессии

Реальный живой баг (2026-09-29, VK2): вспомогательный systemd-таймер, созданный для борьбы с дублями в подписке (см. карточку 29), делал systemctl restart независимого exit-xray процесса каждые 10 минут БЕЗУСЛОВНО. Это рвало вообще все открытые XHTTP packet-up сессии всех пользователей одновременно, каждые 10 минут — в error.log это видно как одновременный всплеск upstream prematurely closed connection и connect() failed (111: Connection refused) строго в моменты рестарта. Симптом с точки зрения пользователя: соединение "иногда работает, иногда нет" — выглядит как проблема сети/CDN, а на самом деле сервер сам себя убивает по расписанию.

Правило: любой systemd-таймер, трогающий exit-xray (или основной x-ui xray) процесс, обязан СРАВНИВАТЬ новое состояние со старым и делать restart ТОЛЬКО если реально что-то изменилось. См. рабочий паттерн в карточке 29.

29. Дедупликация клиентов подписки — паттерн "restart только при реальном изменении"

x-ui периодически (фоновым sync-механизмом) сам возвращает НЕсуффиксованный email клонированного клиента обратно в инбаунд, рядом с суффиксованным клоном — оба с одним subId → в подписке видно 2 одинаковых сервера. Это внутреннее поведение x-ui, не лечится настройками панели, только периодической чисткой. Скрипт дедупа + ресинк exit-конфига должен запускаться часто (проверено: 10 минут — мало, x-ui успевает наплодить дубли снова быстрее; 2 минуты — приемлемо), но restart делать только если набор client id реально изменился — иначе см. карточку 28.

ExecStart=dedupe_clients.py
ExecStart=export inbound.settings -> /tmp/full_clients.json
ExecStart=sync_exit_clients.py  # сравнивает id-set, пишет /tmp/exit_changed = 0|1
ExecStart=bash -c 'if [ "$(cat /tmp/exit_changed)" = "1" ]; then systemctl restart exit-xray; fi'

30. ⚠ x-ui САМ откатывает вручную перенесённый порт инбаунда при каждом своём restart

Если внутренний порт x-ui-инбаунда был сдвинут (например с 9001 на 19001, чтобы освободить 9001 под независимый exit-xray) — этот сдвиг живёт только пока x-ui не перезапускался. При любом systemctl restart x-ui (в том числе случайном, например после серии правок конфига в этот же день) x-ui подтягивает порт заново из какого-то внутреннего состояния и откатывает обратно на исходный. Результат — ДВА процесса слушают один и тот же порт одновременно (ядро линукс случайно распределяет между ними входящие соединения — SO_REUSEPORT-подобное поведение), что выглядит как непредсказуемая нестабильность 40-60% запросов без единой причины в логах.

Проверка после любого restart x-ui: ss -tlnp | grep <порт> — если строк больше одной, порт конфликтует. Чинится через API/БД: выставить порт назад, restart x-ui. Проверять на master И на узле — обычно расходятся независимо.

31. ⚠ КРИТИЧНО: stream{} SNI-демультиплексор — default-фоллбэк НЕ должен указывать в никуда

Архитектура с ssl_preread + proxy_protocol (для случаев когда нужно проксировать несколько доменов с разными сертификатами через один и тот же порт 443) обычно строится как map по SNI: точное совпадение → рабочий бэкенд, всё остальное (default) → куда-то ещё. РЕАЛЬНЫЙ ЖИВОЙ БАГ: если default указывает на порт, где НИЧЕГО не слушает — то ЛЮБОЕ соединение, чей SNI хоть немного отличается от жёстко прописанного (а CDN не гарантирует всегда слать один и тот же SNI на все свои соединения к origin — наблюдалось расхождение в реальном трафике), улетает в пустоту и просто висит до таймаута клиента (не быстрая ошибка — именно долгое зависание, 8-12 секунд). Внешне выглядит как "иногда работает, иногда просто не грузится без объяснений".

Правило: если за одним stream{}-демультиплексором в итоге стоит только ОДИН реальный http-вхост (обслуживающий несколько server_name через обычный nginx server_name matching) — вообще не нужен map/default, просто proxy_pass 127.0.0.1:ПОРТ; без всякой SNI-логики на уровне stream{}. SNI-роутинг на этом уровне нужен, только если РЕАЛЬНО разные бэкенды с разными сертификатами. Проверка: ss -tlnp | grep <fallback-порт> — если пусто, это бомба замедленного действия.

32. proxy_protocol нужен, если Yandex CDN сам оборачивает origin-соединение в PROXY protocol

Живой эксперимент (2026-09-29): попытка убрать stream{}+proxy_protocol слой и посадить XHTTP-вхост прямо на 443 (как в более простой архитектуре без мульти-доменного роутинга) дала СТРОГО худший результат — 0% трафика доходило до origin вообще (клиент получал TLS-уровня зависания на все 12 секунд таймаута). Вывод: если для конкретного CDN-ресурса в консоли Yandex Cloud включена опция "PROXY protocol" для origin-группы — убирать proxy_protocol-обёртку на origin нельзя ни в коем случае, CDN будет слать сырые PROXY-заголовки в TLS-порт напрямую, что ломает TLS handshake полностью. Перед тем как менять архитектуру — сверить с реальными настройками CDN-ресурса.

33. TLS fingerprint клиента (uTLS) — "randomized" может ловить illegal_parameter на некоторых CDN-ресурсах

Живой, стабильно воспроизводимый (100% из 10 попыток) баг: тестовый клиент с fingerprint: "randomized" получал remote error: tls: illegal parameter на КАЖДОМ подключении к одному конкретному CDN-ресурсу (vlom/nevlom), при этом тот же самый клиент с fingerprint: "chrome" или "firefox" — 10/10 успешно, стабильно 0.3-0.5 сек. При этом ДРУГОЙ CDN-ресурс (VK2) с "randomized" работал нормально. Похоже на разницу в настройках TLS /cipher policy между конкретными CDN-ресурсами на стороне Yandex, а не на баг клиента как таковой. Практический вывод: всегда явно ставить fingerprint "chrome" или "firefox" в externalProxy/hosts-таблице для генерации подписки, никогда не оставлять "randomized" — он не даёт преимущества в маскировке (Yandex CDN всё равно термирует TLS сам) и добавляет риск несовместимости без пользы.

34. Методология тестирования: чужой висящий процесс на том же порту убивает достоверность теста

Живой случай: на тестовой VPS почти час висел СТАРЫЙ xray-клиент с конфигом месячной давности, слушавший тот же локальный SOCKS-порт, что и "новый" тестовый клиент — при этом новый процесс молча не мог забиндиться (порт занят) и все "результаты тестов" на самом деле шли через древний, неактуальный конфиг. Перед каждой сессией тестирования: ps aux | grep <имя-бинаря> и pkill ВСЕХ найденных процессов, не полагаться на "наверняка это тот, что я только что запустил".

Второй важный момент: голый curl к origin-адресу без секретного заголовка/is_mobile-флага тестирует только сетевую доступность, а не реальный сценарий клиента — реальная VLESS/XHTTP-сессия требует прохождения geo-фильтра (is_mobile_operator). Если тестовая точка (VPS, роутер) не входит ни в один мобильный диапазон — ЛЮБОЙ настоящий xray-клиент оттуда получит 404 на уровне nginx, что выглядит как "не работает", хотя сервер полностью исправен. Для честного теста с немобильной точки — временно добавить её /32 в mobile_ranges.conf, обязательно убрать после теста (комментарий "# temp test IP" + дата, чтобы не забыть).

Третий момент: после смены архитектуры origin (например временное отключение proxy_protocol для эксперимента) CDN может на какое-то время воспринимать origin как "нездоровый" (health-check/circuit-breaker на стороне CDN) — сразу после отката к рабочей конфигурации несколько тестов подряд всё ещё могут падать, пока CDN не повторно проверит доступность origin. Не паниковать, подождать 20-30 секунд и повторить.

35. Работа с x-ui через панельный API вместо прямых правок sqlite (рекомендовано)

Прямые UPDATE в /etc/x-ui/x-ui.db в обход панели требуют ручной синхронизации master↔узел и легко расходятся (см. карточку 30). Правильный путь — логиниться в панель по HTTP API и слать изменения через неё, тогда панель сама разбирается с синхронизацией на узлы.

# 1. Получить CSRF-токен со страницы логина
curl -sk -c cookies.txt https://HOST:PORT/BASEPATH/ -o index.html
TOKEN=$(grep -o 'csrf-token" content="[^"]*' index.html | cut -d'"' -f3)

# 2. Логин (сохраняет сессионную cookie)
curl -sk -c cookies.txt -b cookies.txt -X POST \
  https://HOST:PORT/BASEPATH/login \
  -H "X-CSRF-Token: $TOKEN" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=USER&password=PASS'

# 3. Любой вызов API с той же cookie+токеном
curl -sk -b cookies.txt -H "X-CSRF-Token: $TOKEN" \
  https://HOST:PORT/BASEPATH/panel/api/inbounds/get/ID

Без правильного CSRF-токена панель отвечает голым 403 без тела ответа — легко спутать с "неверный пароль". Сначала проверить именно заголовок/токен, потом уже перебирать пароли.