Мои заметки
Личный блокнот, виден только тебе (привязан к логину). Заметок может быть сколько угодно, каждая сохраняется отдельно.
Флот роутеров: обратный туннель и 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 без
тела ответа — легко спутать с "неверный пароль". Сначала проверить именно
заголовок/токен, потом уже перебирать пароли.
OWR3030AXG: три прошивки — сисапгрейд, сток→OpenWrt, голая база (2026-11-05)
После починки кухни (баг stale kernel-артефактов + отсутствующий
origo,owr3030axg) case в etc/board.d/02_network, см.
соответствующие записи памяти) штатная сборка на портале для профиля
origo_owr3030axg (кухня /opt/openwrt-imagebuilder/singboxfix)
даёт рабочий образ с LuCI/forkop/sing-box из коробки. На сервере сейчас лежат три
готовых артефакта:
- Сисапгрейд для обновления уже-OpenWrt устройства — обычная кнопка «Собрать
прошивку» на портале (profile
origo_owr3030axg, стандартный пакетluci luci-i18n-base-ru sshtunnel luci-app-sshtunnel luci-i18n-sshtunnel-ru curl openssh-client forkop luci-app-forkop luci-i18n-forkop-ru sing-box-extended kmod-nf-nathelper-pptp). Эталонный прогон:backend/firmware-builds/owr3030axg-fullconfig/*-squashfs-sysupgrade.bin(32 МБ). Прошивается через LuCI «Flash new firmware image» илиsysupgrade -n файл.bin. - Сток→OpenWrt переход, сразу с конфигом — кнопка перехода
(
lib/owrOpenwrtTransition.js,TRANSITION_PROFILES.origo_owr3030axg) собирает carrier на лету из статического payload'аbackend/assets/owr3030axg/openwrt_factory_spifixed.bin. Раньше этот payload был МИНИМАЛЬНЫМ (только фикс сети, без LuCI) — 2026-11-05 заменён на полный factory-образ (LuCI+forkop+sing-box+sshtunnel, те же версии base-files/kernel, что и в сисапгрейде выше). Старый минимальный payload сохранён какopenwrt_factory_spifixed.bin.bak-netfixonly-20261105. Пересборка carrier с новым payload'ом проверена (build_openwrt_carrier.sh→ «VERIFY OK: file is now self-consistent»), живая прошивка этим ПОЛНЫМ payload'ом ещё не делалась (живой прогон 2026-11-05 был на старом, SPI-пофикшенном, но минимальном payload'е) — при следующем переходе проверить LuCI/forkop на устройстве после завершения. - Голая база для мануалов — только
luci luci-i18n-base-ruна базе той же пофикшенной кухни (сеть/LED работают), БЕЗ forkop/sing-box/sshtunnel и БЕЗ оверлеяfiles/(тот оверлей содержитetc/shadow/приватный SSH-ключ флота — в голую базу намеренно не включён).backend/firmware-builds/manual-owr3030axg-bare/*-squashfs-{factory,sysupgrade}.bin(~9.4-9.8 МБ). Подходит как чистая отправная точка — поставить LuCI-доступ без пароля root и накатывать нужные пакеты вручную.
LED-маппинг: твёрдо работают status и 2g; LAN1-3
светодиоды физически не достижимы через проверенные SoC GPIO (см. память
project_owr3030axg_led_gpio_live_mapping) — во всех трёх образах выше это
ограничение одинаковое, не регрессия конкретной сборки.
SNR-CPE-AX2: откат OpenWrt → сток EasyWRT (2026-11-04)
После перехода сток→OpenWrt (см. карточку перехода и
lib/openwrtTransition.js) U-Boot и схема UBI-томов меняются на новую
(fit/rootfs_data/ubootenv/ubootenv2).
Обычный вендорский sysupgrade-тарбол (старый формат CONTROL+kernel+root) после этого
НЕ прошивается напрямую — текущий sysupgrade проверяет образ через
/usr/libexec/validate_firmware_image, и для устройства с новой (fit-based)
схемой старый формат получает forceable: false — -F не спасает,
результат: Invalid image type. Image check failed.
Путь 1 (подтверждён живым тестом 2026-11-04): recovery-режим U-Boot
Надёжный путь, не зависящий от sysupgrade/fwtool-валидации — работает на
уровне самого U-Boot, который явно поддерживает это в загрузочном меню
(4. Boot recovery system from NAND. / 6. Load recovery system via
TFTP then write to NAND.).
Фаза 1 (с живого OpenWrt, по SSH или UART):
1. Залить initramfs-recovery.itb в том "recovery":
ubimkvol /dev/ubi0 -N recovery -s <размер .itb>
ubiupdatevol /dev/ubi0_<id recovery> /tmp/initramfs-recovery.itb
сверить md5 (dd+head -c <точный размер>+md5sum vs md5sum файла)
2. fw_setenv bootcmd 'run boot_recovery'
3. reboot
Фаза 2 (в recovery — чистое RAM-окружение, dropbear SSH, root без пароля, 192.168.1.1):
1. Снести ВСЕ тома новой схемы:
ubirmvol /dev/ubi0 -N fit
ubirmvol /dev/ubi0 -N rootfs_data
ubirmvol /dev/ubi0 -N ubootenv
ubirmvol /dev/ubi0 -N ubootenv2
ubirmvol /dev/ubi0 -N recovery
2. Восстановить kernel/rootfs СТАРОГО стокового слота из снимка/бэкапа
(имена томов ДОЛЖНЫ совпадать с тем, что было снято при переходе —
dual_boot.current_slot не трогался ни разу за весь цикл):
ubimkvol /dev/ubi0 -N kernel -s <размер kernel.bin>
ubiupdatevol /dev/ubi0_<id> /tmp/kernel.bin
ubimkvol /dev/ubi0 -N rootfs -s <размер rootfs.bin>
ubiupdatevol /dev/ubi0_<id> /tmp/rootfs.bin
3. Вернуть старый СТОКОВЫЙ FIP/бутлоадер:
mtd write /tmp/stock-fip.bin FIP
4. reboot
⚠ Для Фазы 2 нужен и старый стоковый FIP (бутлоадер), и оригинальные
kernel/rootfs — их надо было снять ДО перехода сток→OpenWrt (снимок делает
openwrtTransition.js автоматически в
backend/backups/ax2-stock-snapshots/<tempId>/ при переходе через сам
rt-portal). Если переход на конкретном юните делался не через rt-portal, а вручную
(как было на тестовом стенде) — такого снимка нет, и Фазу 2 нельзя пройти без него.
Путь 2 (НЕ РАБОТАЕТ как есть, подтверждено живым тестом 2026-11-04): sysupgrade-хук
Задуман как способ без recovery-режима вообще — обычный
sysupgrade -n -F rollback-package.bin (или кнопка «Flash new firmware image»
в LuCI) с хуком /lib/upgrade/zzz-ax2-rollback.sh, который должен
переопределять platform_do_upgrade и напрямую переписывать UBI-тома из
пакета (RTPORTAL-AX2-ROLLBACK-V1 + tar с fip.bin/
kernel.bin/rootfs.bin).
Живой тест на юните 8c:ae:db:7c:6d:a9 (COM3 115200): хук установлен,
chmod +x, validate_firmware_image вернул
forceable: true — ранний чек в /sbin/sysupgrade пройден.
Но дальше процесс дошёл до stage2 (переключение на RAM-диск, остановка
сервисов) и упал на штатной проверке verifying sysupgrade tar file integrity
→ tar: invalid tar magic → corrupted sysupgrade tar file →
sysupgrade failed. То есть вызывается обычный (легаси-тарбол) путь
default_do_upgrade, а НЕ наша функция rtportal_rollback_do() —
предположение авторов хука про алфавитную загрузку /lib/upgrade/*.sh
(комментарий в начале файла) на практике не сработало так, как ожидалось. Устройство
после этого безопасно перезагрузилось обратно в то же OpenWrt — не бэкнулось, но и откат
не произошёл.
⚠ Этот путь требует доработки хука (разобраться, почему
platform_do_upgrade не перехватывается, и/или добавить явный вызов
rtportal_rollback_do раньше генерик-проверки) и ПОВТОРНОЙ живой проверки,
прежде чем предлагать его как штатный способ техникам. До этого — пользоваться только
Путём 1.
Практические заметки по этому юниту
- MAC юнита (
eth0):8c:ae:db:7c:6d:a9— ровно под этим именем лежит его персональныйrollback-package.binна rt-portal (backend/backups/ax2-stock-snapshots/8c:ae:db:7c:6d:a9-rollback-package.bin, md579f70aad1c554ccca766138d2576c044). Перед заливкой ЛЮБОГО rollback-пакета на юнит — сверять MAC, иначе риск залить чужой снимок. - Отдельно на сервере сборки (
.67:/ssd/leks/comfast-25.12.5/ax2_rollback/) лежит сырой вендорский sysupgrade-тарбол этого же юнита (snr-cpe-ax2-stock-rollback.tar, sha2562edb618b0ed7f53813b4007fd53895a40477b235c188d031bcc833b3e00445b6) — это НЕ готовый rollback-пакет для текущей (fit-based) схемы, а просто архивная копия содержимого стока; прямойsysupgrade -n -Fэтим файлом на уже перешедшем на OpenWrt юните даётInvalid image type(см. Путь 2 выше, тот же класс проблемы, только без хука вообще). - SSH-канал обрывается штатно на шаге «Closing all shell sessions» — любое наблюдение за процессом флешинга/ребута делать через UART (COM-порт, 115200), не по сети.
- SFTP (sftp-server) на dropbear этого юнита не настроен — бинарные файлы заливать
через
cat > /path/fileна exec-канале SSH (без PTY, бинарно чисто), не черезopen_sftp().
almonds3 (Securifi Almond 3S, ramips/mt7621, mipsel_24kc) — живой снимок конфига для пересборки кухни (2026-11-11)
Базовая прошивка — НЕ стоковый rt-portal kitchen-образ, а сторонняя сборка
под конкретное железо: openwrt-almond3s (гитхаб-юзер do),
OpenWrt 25.12.5 r33051-f5dae5ece4, target ramips/mt7621, arch
mipsel_24kc. Флотовые компоненты rt-portal (sshtunnel+
luci-app-sshtunnel, tachyon 1.7.4 + tachyon-core
0.2.9, wireguard-tools+kmod-wireguard,
/etc/init.d/rtprovision) добавлены НА этот образ отдельно. При следующей
пересборке кухни под mt7621 — если нужно добавить это устройство как профиль,
брать базовую сборку openwrt-almond3s как основу (кастомные пакеты под LCD/кнопки
Almond 3S: kmod-lcd-almond3s, lcd-ui-almond3s,
nes-almond3s; 5G-модем Fibocom L850-GL: luci-app-5gmodem-lite,
libmbim/libqmi/uqmi/umbim/mbim-utils)
и накатывать сверху тот же набор, что в стандартном профиле
files_tachyon кухни /opt/openwrt-imagebuilder/25.12.5-mt7621/.
Полный живой снимок устройства (локально в памяти Claude, не на портале):
полный uci export (882 строки) и полный список пакетов
apk list --installed (267 пакетов) — файлы
almonds3_uci_export_20261111.txt и almonds3_packages_20261111.txt
в заметках памяти проекта project_almonds3_router_setup. Ключевое отличие от
эталонной кухни: zapret/zapret2/byedpi НЕ входят в базовый образ — ставятся
ПОСЛЕ прошивки через встроенный компонент-менеджер tachyon (см. ниже), не через opkg/apk
feed и не руками с GitHub.
YouTube-обход через tachyon встроенным компонент-менеджером (не вручную!)
Урок с almonds3: zapret2-бинарники НЕ нужно тащить вручную с
remittor/zapret-openwrt или копировать с другого роутера флота (риск
архитектурного мисматча — COMFAST = aarch64_cortex-a53, almonds3 =
mipsel_24kc). У tachyon есть собственный компонент-менеджер:
tachyon component_list_releases zapret2— список релизов (собственный репозиторий автора тахионаgithub.com/Dushnilin/zapret2-openwrt, подписанzapret2-dushnilin.pub).tachyon component_install_version zapret2 v1.0.7— сам резолвит архитектуру устройства и ставит готовый бинарник в/opt/zapret2/nfq2/nfqws2.- UCI-секция правила обхода (схема общая у tachyon/forkop через модуль
connections.uc— принимаетcommunity_listsна любойsectionнезависимо отaction):config section 'youtube_zapret2' option label 'YouTube via Zapret2' option enabled '1' option action 'zapret2' list community_lists 'youtube'Применить:tachyon reload(первый старт демона может занять >60-120с — не дублировать команду, ждать). - Проверка:
tachyon get_zapret2_status→ready:true,running_process_count:1,routes_configured:true;tachyon check_nft_rules→ таблица/правила nftables на месте. - ⚠ Простой
curlна youtube.com/ytimg.com/googlevideo с роутера НЕ показывает разницы включён/выключен zapret2 — РФ-DPI троттлит видеопоток (QUIC/CDN googlevideo при реальном проигрывании), а не открытие страницы. Curl-тест ничего не доказывает — реальная проверка только с клиентского устройства за роутером при живом просмотре видео.
almonds3 — failover без health-check + WG point-to-point не ходит трафик (2026-11-11)
Три отдельных проблемы со связью на almonds3 — пропажа из rt-portal, и
отказ домена tv.smartup.tv/kt-tv.ru грузиться через отдельный
WireGuard-тунель на MikroTik, несмотря на формально верный роутинг.
1. Статический failover без реального теста связи
3-уровневый аплинк (мобильный→проводной→WiFi) был реализован ТОЛЬКО
статическими UCI-метриками (network.modem.metric=10,
wan.metric=20, wwifi.metric=30) — без какого-либо
health-check. После ребута мобильный модем был технически "up" (DHCP выдан), но реально
мёртв (0% ping) — роутер всё равно держал его как дефолтный маршрут, инет полностью
пропадал. Фикс — поставить mwan3 с реальным ping-тестом
(track_method 'ping', track_ip 1.1.1.1/8.8.8.8), сохранив тот же
порядок приоритета через metric у member-секций.
mwan3 status подтверждает живое детектирование мёртвого линка и автопереключение.
2. Аналог "hosts" в tachyon, чтобы домен шёл через отдельный WG-тунель
Нужно было, чтобы 2 домена + 2 IP НЕ захватывались обычной логикой tachyon
(community_lists/zapret), а уходили в обычную системную маршрутизацию, где их подхватывает
уже настроенный WireGuard через AllowedIPs. Аналог "hosts"-исключения в
tachyon — секция с action='bypass':
config section 'tv_wg_bypass'
option label 'TV via WG bypass'
option enabled '1'
option action 'bypass'
option community_subnets '0'
list domain 'tv.smartup.tv'
list domain 'kt-tv.ru'
list ip_cidr '185.106.56.5/32'
list ip_cidr '185.106.56.2/32'
Важно: для IP ключ именно ip_cidr (не ip/ips).
Bypass/block-секции обрабатываются с наивысшим приоритетом — раньше capture-секций
(rtportal/youtube_zapret2), поэтому их трафик гарантированно не попадает под общую
маркировку 0x04000000. Применять через tachyon reload +
tachyon engine_reload <reason> — ⚠ просто engine_apply в
этом случае НЕ подтянул изменение (config_plan показывал "No changes
detected" несмотря на явный uci commit).
3. WireGuard AllowedIPs — это и роутинг, и фильтр на приём
Даже после верного роутинга трафик с LAN-клиента (телефон) в тунель не шёл —
SYN уходил (счётчик tx рос), ответа не было (rx почти не рос), conntrack пуст.
При этом ручной curl --interface wg_xxx с самого роутера работал отлично.
Причина: AllowedIPs на peer-записи WireGuard работает в обе стороны — не
только как таблица маршрутизации для исходящих пакетов, но и как фильтр на ПРИЁМ: удалённый
пир (MikroTik) тихо дропает расшифрованный пакет, если его source IP не входит в
сконфигурированный для этого peer'а список AllowedIPs. Тунель был
point-to-point с узким списком конкретных /32-адресов, а LAN-трафик уходил в тунель БЕЗ
NAT — с исходным LAN-адресом клиента (например 192.168.1.232), которого на
стороне MikroTik в разрешённом списке нет. Пакет роутера с source =
адрес самого тунеля (внутри AllowedIPs) проходил всегда — отсюда ложное ощущение "роутинг
верный, а трафик не ходит".
Фикс: на firewall-зоне, привязанной к этому WG-интерфейсу, включить
masq='1' (uci set firewall.@zone[N].masq='1') — это добавляет
безусловный MASQUERADE для исходящего в тунель трафика, подменяя LAN-source на адрес
тунеля. После этого wg show <iface> transfer сразу показал реальный
обмен (десятки КБ вместо почти нулевого keepalive-трафика).
⚠ Общий урок: если прокидываете трафик LAN-клиентов через узкий
point-to-point WireGuard-тунель к конкретным адресам (а не через 0.0.0.0/0) — диагностику
начинать с wg show transfer до/после теста. Растущий tx при почти неизменном
rx — это не проблема DNS/маршрутизации/firewall на своей стороне, а дроп на удалённом пире
по source IP, и первым кандидатом на фикс — masquerade, а не перепроверка routing table.