Мои заметки
Личный блокнот, виден только тебе (привязан к логину). Заметок может быть сколько угодно, каждая сохраняется отдельно.
Флот роутеров: обратный туннель и 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-ресурс снова заблокирован
- Проверить в панели 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), не тратить время на отладку конфигурации.
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 без
тела ответа — легко спутать с "неверный пароль". Сначала проверить именно
заголовок/токен, потом уже перебирать пароли.