Зачем

Задача обычная: дома всё должно просто работать. Российские сервисы — банки, госуслуги, маркетплейсы — открываются напрямую с домашнего 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

домены .ru, .su, .рф

напрямую

8

geosite-category-ru — российские сервисы не в зоне.ru

напрямую

9

geoip-ru — российские IP

напрямую

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 мин

ручной выбор ноды → обратно на auto; принудительно увести группы с мёртвых нод

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 до и после.