
Зачем
Задача обычная: дома всё должно просто работать. Российские сервисы — банки, госуслуги, маркетплейсы — открываются напрямую с домашнего IP, а всё остальное идёт через VPN. На каждом устройстве ничего не настраиваем: ни на телевизоре, ни на телефонах гостей.
Первая версия на WireGuard и ручных списках работала, пока всё было хорошо. Плохо становилось регулярно: провайдер VPN менял адреса серверов, ноды умирали по одной, а однажды обычный перезапуск службы оставил всю квартиру без интернета. Каждый раз нужен был человек с ноутбуком. Хотелось, чтобы система зависела только от провайдера VPN, а всё остальное переживала сама.
Что получилось:
Сводка vpn и журнал автоматики во время смены адресов у провайдера
Настоящий журнал за утро: провайдер сменил адреса серверов, сторож перепроверил ноды, перезапустил sing‑box, а когда понял, что мертвы все ноды одного провайдера, сам скачал свежую подписку. Названия нод скрыты.
Железо и софт
Xiaomi AX3000T: MT7981, 2 ядра ARM64, 256 MB RAM (доступно ~234), 128 MB NAND. Стоит около 3 тысяч рублей.
OpenWrt 25.12, пакеты через
apk.sing‑box 1.13 в режиме TUN.
Стоит за роутером провайдера (двойной NAT), тариф 100 Мбит/с.
Через VPN получается ~90 Мбит/с при тарифе 100, CPU роутера при этом загружен на 73–83%. Упираемся в тариф, а не в шифрование: Reality на этом железе не узкое место. Свою память sing‑box держит в районе 15 MB.
sing‑box в режиме TUN сам создаёт tun0 и правила маршрутизации. В OpenWrt остаётся описать интерфейс, чтобы дать ему зону firewall с маскарадингом и разрешить пересылку LAN → VPN. Свои соединения (к нодам и «напрямую») sing‑box привязывает к WAN (auto_detect_interface), поэтому не заворачивает их в собственный туннель.
Что считать российским
Правила проверяются по порядку, первое сработавшее решает:
# | Условие | Куда |
|---|---|---|
1 | IP и домены самих нод | напрямую (иначе туннель в туннеле) |
2 | Tailscale: домены, CGNAT 100.64.0.0/10, порт 41 641 | напрямую |
3 | NTP (UDP 123) | напрямую: у роутера нет часов с батарейкой, без времени не поднимется TLS |
4 | ручной список «всегда через VPN» | VPN |
5 | частные адреса | напрямую |
6 | ручной список «всегда напрямую» | напрямую |
7 | домены | напрямую |
8 |
| напрямую |
9 |
| напрямую |
10 | QUIC (UDP 443), не попавший выше | reject — браузер сразу уйдёт на TCP через VPN |
— | всё остальное | VPN |
Последнее правило — про QUIC, который не попал ни под одно правило «напрямую»: reject заставляет браузер сразу перейти на TCP, а TCP уже идёт через VPN.
Базы geoip и geosite — от SagerNet. Раз в неделю их обновляет скрипт, но ставит новую базу, только если файл скачался целиком, читается sing‑box и конфиг с ним валиден. Иначе остаётся старая.
Ручные списки правятся одной командой, без перезапуска: vpn direct сайт, vpn proxy сайт. sing‑box подхватывает изменённый файл rule‑set на лету.
DNS: самое неочевидное
DNS тоже раздельный и живёт внутри sing‑box. Российские имена спрашиваются у Яндекса напрямую, все остальные — у Cloudflare DoH через VPN. Зарубежные имена в российский DNS не утекают, а российские сервисы получают адреса, правильные для домашнего IP.
устройство :53 → dnsmasq :53 (кэш) 1) 127.0.0.1#5300 sing-box: российское → 77.88.8.8 напрямую, остальное → DoH 1.1.1.1 через VPN 2) 127.0.0.1#5053 https-dns-proxy (Cloudflare DoH) — если sing-box не ответил 3) DNS роутера выше — последний резерв, не зависит ни от VPN, ни от Яндекса
1) 127.0.0.1#5300 sing-box: российское → 77.88.8.8 напрямую, остальное → DoH 1.1.1.1 через VPN
2) 127.0.0.1#5053 https-dns-proxy (Cloudflare DoH) — если sing-box не ответил
3) DNS роутера выше — последний резерв, не зависит ни от VPN, ни от Яндекса
Грабля № 1: rule‑set с IP в DNS‑правиле. Сначала ручные списки были общими: домены и подсети в одном файле. Оказалось, если rule‑set в DNS‑правиле содержит ip_cidr, sing‑box превращает правило в фильтр по ответу: сначала спрашивает этот сервер про каждое имя, а потом смотрит, попал ли ответ в подсеть. То есть имена всех сайтов уходили бы в российский DNS. Решение — разделить списки: доменные файлы участвуют и в DNS, и в маршрутизации, IP‑файлы — только в маршрутизации.
Грабля № 2: как dnsmasq переходит на запасной сервер. С strict-order dnsmasq идёт к следующему серверу только на повторном запросе клиента. А sing‑box при недоступном Яндексе не отвечает вовсе (не SERVFAIL). Значит, при сбое второй уровень отвечает примерно за 2 секунды, третий — за 4. Медленно, но работает: когда VPN лежит, российские сайты продолжают открываться, а раньше в такие моменты пропадал весь DNS.
Мелочи, которые закрывают обходы:
mask.icloud.comиuse-application-dns.netв dnsmasq заглушены. Так iCloud Private Relay и DoH в Firefox не уводят DNS мимо роутера;https‑dns‑proxy запрещает устройствам DNS к чужим серверам на портах 53 и 853. Телевизор с «зашитым» 8.8.8.8 получит отказ и спросит роутер;
IPv6 выключен целиком. Туннель здесь только IPv4, и при IPv6 от провайдера устройства пошли бы мимо VPN.
Подписки
Ноды приходят из двух подписок от разных провайдеров. Скрипт на python раз в 30 минут:
качает подписку через VPN, а если не вышло — напрямую (через WAN, адрес сервера узнаёт у Яндекса). Иначе при мёртвых нодах подписку не скачать: DNS для зарубежного идёт через тот же мёртвый VPN;
фильтрует ноды по словам в названии и отбрасывает транспорты, которые не поддерживаются;
если подписка не скачалась — оставляет её прежние ноды, а не выкидывает;
сравнивает ноды по сути (сервер, порт, UUID, ключ), а не целиком. Один провайдер при каждой выдаче подставляет случайные SNI и short_id, и без этого sing‑box перезапускался бы каждые полчаса впустую;
пересобирает группы
urltest, вставляет правила «к нодам — напрямую» и пишет конфиг атомарно;держит жёсткий лимит 240 секунд на всю работу (SIGALRM). Сервер подписки, отвечающий по байту, не должен держать общую блокировку.
Дальше apply-vless.sh проверяет конфиг (sing-box check), безопасно перезапускает sing‑box и ждёт, пока туннель реально заработает. Если связи нет — откат на прежний конфиг. Если и с прежним нет, он смотрит, живы ли ноды: мертвы — значит, это провайдер VPN, и роутер ничего не трогает; живы, а трафик не идёт — поломка у нас, тогда network restart и в крайнем случае перезагрузка, не чаще раза в 6 часов.
Сторож
sb-watchdog.sh запускается из cron раз в минуту. Пока всё работает — молчит и укладывается меньше чем в секунду. Главное в нём — честные проверки, не зависящие от состояния sing‑box и DNS:
direct_ok() # интернет у провайдера: ping через WAN-устройство до 77.88.8.8 / 8.8.8.8 — без DNS, мимо туннеляtunnel_ok() # VPN реально работает: https://1.1.1.1/cdn-cgi/trace показывает страну не RU — по IP, без DNSrouting_ok() # tun0 поднят и есть ip rules sing-boxproviders_ok # хоть одна из 3 лучших нод каждого провайдера отвечает (поштучная проверка через Clash API)
tunnel_ok() # VPN реально работает: https://1.1.1.1/cdn-cgi/trace показывает страну не RU — по IP, без DNS
routing_ok() # tun0 поднят и есть ip rules sing-box
providers_ok # хоть одна из 3 лучших нод каждого провайдера отвечает (поштучная проверка через Clash API)
Сначала сторож проверял «открывается ли gstatic». Это ловушка: gstatic открывается и напрямую, поэтому зависший sing‑box выглядел здоровым. Проверка по loc в Cloudflare trace отличает «трафик идёт через VPN» от «трафик идёт хоть как‑то».
Что делает сторож:
Сколько VPN не работает | Действие |
|---|---|
sing‑box не запущен | безопасный запуск (procd бросает службу после 5 падений за час, а сторож — нет) |
нет tun0 или ip rules 2 мин | безопасный перезапуск |
2 мин | ручной выбор ноды → обратно на |
3 мин | обновить подписки: вдруг провайдер сменил адреса (если адреса те же — без перезапуска) |
5 мин, ноды живы, трафик не идёт | безопасный перезапуск (не чаще раза в 30 мин) |
все ноды одного провайдера мертвы 3 мин | обновить подписки |
мертвы все ноды при живом интернете | обновить подписки напрямую; сеть и роутер не трогать |
Грабля № 3: «принудительный перетест» ничего не перетестировал. В sing‑box 1.13 /group/<имя>/delay в Clash API перепроверяет только ноды с устаревшей историей, а при свежей отвечает {}. Работает только поштучная проверка /proxies/<нода>/delay. Побочный эффект: неудачная проверка стирает историю ноды, и группа перестаёт её выбирать. Из этого получилась функция kick_groups — увести группу с мёртвой ноды сейчас, а не через интервал urltest.
Безопасный перезапуск
Грабля № 4, самая дорогая. Обычный /etc/init.d/sing-box restart однажды оставил LAN без интернета: при быстром перезапуске новое состояние ядра не успевало правильно подняться. Теперь перезапуск только один:
общая блокировка → stop → 3 с → sing-box check (невалиден — вернуть последний рабочий конфиг)→ start → дождаться tun0 и ip rules → проверить, что tun0 есть во flowtable (иначе fw4 reload)
→ start → дождаться tun0 и ip rules → проверить, что tun0 есть во flowtable (иначе fw4 reload)
Им пользуются все: человек (vpn restart), сторож, обновление подписок и даже procd‑триггер на подъём WAN, который раньше дёргал тот самый быстрый restart. Все скрипты делят одну блокировку, поэтому не мешают друг другу. Ещё есть config.json.good — последний конфиг, с которым связь точно была. Испорченный конфиг автоматика заменит им сама.
Tailscale: доступ, который не ломается вместе с VPN
На роутере стоит Tailscale — удалённый доступ и exit node. Важная деталь: tailscaled метит свои пакеты fwmark 0x80000, и они уходят по отдельному ip rule в основную таблицу, мимо sing‑box. Поэтому зайти на роутер можно, даже когда sing‑box упал или туннель сломан, — ровно тогда, когда это нужнее всего.
Отдельный урок про стабильность: раньше при каждой загрузке скрипт перезапускал tailscaled и делал tailscale up --reset. Отсюда «включается и выключается», а иногда и разлогин. Теперь настройки живут в state‑файле, меняются только tailscale set, а за живостью следит свой сторож — он опрашивает локальный сокет, а не запускает 26-мегабайтный бинарник раз в две минуты.
Память на 256 MB
procd_set_param env GOGC=30 GOMEMLIMIT=45MiB
Однажды пользователь (то есть я) испугался: «sing‑box уже 47 MB!» Разбор показал, что VmRSS — это сумма рабочей памяти (RssAnon, ~15 MB) и кода программы (RssFile, ~33 MB). Код ядро при нехватке выкидывает и подчитывает с флеша заново, поэтому смотреть надо на RssAnon. Скрипты mem и health.sh теперь показывают обе цифры отдельно. Плюс zram на 150 MB как запас при всплесках.
Один интерфейс для человека
Всё сведено в команду vpn — её вид на скриншоте выше:
vpn # сводка: интернет, VPN (страна выхода, нода), sing-box, Tailscale, событияvpn site ozon.ru # куда пойдёт сайт и по какому правилуvpn direct сайт # всегда напрямую (vpn proxy — всегда через VPN)vpn use 3 / vpn auto # выбрать ноду вручную / вернуть автовыбор (сторож вернёт сам, если нода умрёт)vpn report 24 # отчёт о здоровье за сутки с итогом «всё хорошо / есть на что посмотреть»vpn restart # безопасный перезапуск
vpn site ozon.ru # куда пойдёт сайт и по какому правилу
vpn direct сайт # всегда напрямую (vpn proxy — всегда через VPN)
vpn use 3 / vpn auto # выбрать ноду вручную / вернуть автовыбор (сторож вернёт сам, если нода умрёт)
vpn report 24 # отчёт о здоровье за сутки с итогом «всё хорошо / есть на что посмотреть»
vpn restart # безопасный перезапуск
Дважды в день cron сохраняет отчёт: провалы VPN и провайдера, память, сгруппированные события. Утром за кофе видно, что ночью произошло и как автоматика с этим справилась.
Как это сработало вживую
Через сутки после внедрения: VPN не отвечал 3 раза, каждый раз меньше 5 минут, и каждый раз сторож вернул его сам. За ночь до внедрения таких провалов было 8, в том числе час подряд.
На следующее утро провайдер сменил адреса всех серверов — это журнал на скриншоте. Связи не было 19 минут, и это показало слабое место: подписки тогда скачивались раз в час. Теперь сторож на третьей минуте без VPN сам проверяет, не сменились ли адреса, а cron проверяет подписки раз в 30 минут.
Про ИИ‑агента
Роутер я обслуживаю вместе с Claude Code. Это оказалось удобнее, чем звучит, если правильно устроить рабочую папку:
CLAUDE.md— “золотые правила”: сначала спроси, потом меняй (ошибка = семья без интернета); перезапуск только через безопасный скрипт; любую правку конфига — сначала на копии иsing-box check; не запускать тяжёлое около:07 и:37, когда работает cron;docs/history.md— история каждого сбоя и решения с причиной. Агент не переигрывает уже принятые решения и не наступает на те же грабли;docs/resilience-audit.md— аудит: 14 найденных дыр и как каждая закрыта и проверена;mirror/— снимок конфигов с роутера для чтения; источник правды — сам роутер.
Агент ходит на роутер по ssh через Tailscale, ведёт журнал изменений и записывает уроки. Однажды при пробном прогоне сторожа на копии случился лишний перезапуск: заглушку перекрыла функция, объявленная в самом скрипте. Теперь это отдельное правило в CLAUDE.md.
Собрать себе
Репозиторий на Гитхабе (MIT).
Там очищенная копия: адреса, ноды, ключи и подписки заменены заглушками, провайдеры названы P1 и P2. Есть пошаговая инструкция с нуля — пакеты, какой файл куда, одна или две подписки, первый запуск, что делать, если не заработало. Я проверил её честно: собрал роутер с нуля в виртуалке с OpenWrt для arm64 и нашёл 8 собственных ошибок — от tar, который делал /usr чужим, до забытого IPv6, по которому устройства ходили бы мимо VPN.
Буду рад критике — особенно логики сторожа (где проходит граница между «перезапустить» и «подождать, пока оживёт провайдер») и цепочки DNS. И если что‑то из этого sing‑box умеет «из коробки», а я сделал руками, — напишите.
UPD 06.10.2026. За два дня после написания статьи кое-что поменялось. Всё уже в репозитории.
Пошаговая инструкция, как собрать такой роутер с нуля, лежит на GitHub в файле docs/setup.md. Как подключить уведомления и команды в Telegram — в docs/telegram.md.
Сторож реагирует раньше. Чаще всего VPN пропадает так: выбранная нода вдруг перестаёт принимать TCP, а urltest уходит с неё только на плановой проверке раз в 5 минут. Теперь сторож уже на первой минуте проверяет выбранную ноду. Если она не ответила два раза подряд, он уводит с неё группу. Пока нода отвечает, ничего не переключается, потому что каждое переключение рвёт соединения. Перезапуск при «ноды живы, а трафик не идёт» теперь с 4-й минуты, а не с 5-й. Ещё у сторожа появился замок: во время сбоя один его проход может идти дольше минуты, и cron запускал второй экземпляр, который считал минуты повторно.
apply-vless больше не чинит чужую поломку. Нашлась цепочка, которая из-за сбоя у провайдера VPN могла довести до перезагрузки роутера. VPN уже лежит, сторож обновляет подписки, следует перезапуск, а связи нет. Дальше откат, связи всё равно нет, ноды при этом отвечают на проверку, значит network restart, а за ним перезагрузка. Теперь apply-vless перед перезапуском запоминает, работал ли VPN. Если не работал, новый конфиг остаётся, а откат, сброс сети и перезагрузка не выполняются. Дальше разбирается сторож.
Запасной DNS под присмотром. После долгого обрыва у провайдера оказалось, что https-dns-proxy (второй уровень DNS) включён, но не запущен: procd бросил его после серии падений. Теперь сторож поднимает его сам. Тут есть грабля: stop у этой службы возвращает в dnsmasq серверы 1.1.1.1 и 8.8.8.8, поэтому только start.
TCP-сироты. В начале одного сбоя в dmesg появилось «TCP: too many orphaned sockets»: соединения к мёртвой ноде висели минутами. Поставил tcp_orphan_retries=3 (по умолчанию 0, а это значит 8 повторов) и tcp_max_orphans=2048.
Telegram. Роутер сам пишет, когда:
VPN не работает дольше 10 минут и когда он вернулся;
у провайдера пропадал интернет;
была перезагрузка (сколько он был выключен и почему);
произошли важные события автоматики.
Сообщение сначала отправляется через VPN. Если не вышло, то напрямую через WAN, а если и так не ушло, ждёт в очереди. Роутеру можно писать команды: статус, ноды, куда пойдёт сайт, автовыбор, обновить подписки, замер скорости. Перезапуск и перезагрузка выполняются только после подтверждения кнопкой. Команды принимаются только из одного чата.
Пульс. О своей смерти роутер сообщить не может, поэтому раз в 5 минут он пингует внешний сервис напрямую, мимо VPN. У меня это Cronitor, подойдёт любой сервис с адресом вида https://…. Если пульсы пропали, тревогу присылает сам сервис. Грабля: в мониторе нужно задать расписание и grace period, иначе сервис не ждёт пульсов и молчит. Я выяснил это, когда роутер 10 часов был без интернета, а тревога так и не пришла.
vpn day. Показывает сутки по часам одной строкой: цветом отмечено, работал ли VPN, отдельно отмечены часы без интернета у провайдера. vpn day 7 показывает неделю.
Безопасность. По итогам аудита:
update-vless.pyберёт адрес сервера ноды, только если это публичный IP или корректное имя хоста;правило «к нодам напрямую» строится по точным именам серверов, а не по домену второго уровня;
запасной путь скачивания подписки не идёт по редиректу на не-https;
конфиг с ключами получил права 600;
SSH теперь только по ключу.
OpenWrt 25.12.5. Обновился через owut, роутер вернулся за 75 секунд, но сборка кое-что потеряла:
закрепления версий sing-box и tailscale в
/etc/apk/world;avahi: в 25.12.5 пакет переименован в
avahi-dbus-daemon, и сервер сборки молча выкинул его вместе с dbus;два своих файла, которых не было в
sysupgrade.conf.
Совет: перед обновлением сверить свои файлы с sysupgrade -l, а после сравнить вывод apk info до и после.

