OpenWrt + policy routing: два аплинка и split-tunnel

Два канала в квартиру и желание разложить трафик по ним осмысленно. Маршрутизация по назначению тут не поможет — нужна маршрутизация по источнику.

Вводная: mini-PC с OpenWrt, два аплинка — основной кабельный и резервный LTE-свисток. Обычная таблица маршрутизации умеет отвечать на вопрос «куда идёт пакет» и понятия не имеет о вопросе «от кого он». А мне нужно ровно второе: рабочий ноут и стенд — в один канал, телевизор и гостевой wifi — в другой.

Это и называется policy routing: решение принимается не по destination, а по любому признаку, какой сумеем навесить.

Как это устроено

Механика состоит из трёх частей, и путаница обычно возникает потому, что их смешивают:

  1. Несколько таблиц маршрутизации. Не одна main, а ещё своя на каждый аплинк.
  2. Метка (fwmark). Ставится на пакет файрволом по любому критерию.
  3. Правило (ip rule). Говорит: «пакет с такой меткой — смотри в такую таблицу».

Заводим таблицы:

# /etc/iproute2/rt_tables
100  wan_main
200  wan_lte

Наполняем — в каждой ровно один маршрут по умолчанию, в свой шлюз:

ip route add default via 192.168.1.1 dev eth1 table wan_main
ip route add default via 192.168.8.1 dev wwan0 table wan_lte

И связываем метку с таблицей:

ip rule add fwmark 0x100 lookup wan_main priority 100
ip rule add fwmark 0x200 lookup wan_lte  priority 200

Маркировка

Теперь собственно политика. В OpenWrt файрвол — nftables (в свежих версиях), метки ставим в mangle prerouting:

table inet mangle {
  chain prerouting {
    type filter hook prerouting priority mangle; policy accept;

    ip saddr 192.168.10.0/24 meta mark set 0x200   # гостевая сеть -> LTE
    ip saddr 192.168.1.50    meta mark set 0x200   # телевизор     -> LTE
    ip saddr 192.168.1.0/24  meta mark set 0x100   # остальные     -> кабель
  }
}

Порядок важен: последнее совпавшее правило перезапишет метку, поэтому частные случаи идут выше общих.

Грабля: обратный трафик

Вот здесь ломаются все с первого раза, и я сломался тоже.

Метка живёт на пакете, а пакет — один. Запрос ушёл через LTE, ответ приходит на интерфейс LTE — и роутер, маршрутизируя его назад в локалку, метки уже не имеет. Для транзитного трафика это чаще всего неважно, но как только у роутера появляются свои соединения или проброшенные порты — начинается асимметрия: пришло по LTE, ушло по кабелю, ответ отброшен как нежданный.

Лечится связкой connmark: метку запоминаем на соединении, а не на пакете.

table inet mangle {
  chain prerouting {
    type filter hook prerouting priority mangle; policy accept;

    # у известного соединения — восстановить метку и больше не думать
    ct mark != 0x0 meta mark set ct mark accept

    ip saddr 192.168.10.0/24 meta mark set 0x200
    ip saddr 192.168.1.0/24  meta mark set 0x100

    # запомнить решение на соединении
    meta mark != 0x0 ct mark set meta mark
  }
}

Смысл: решение принимается один раз, на первом пакете соединения, и дальше просто восстанавливается из conntrack. Заодно дешевле — не гоняем правила на каждый пакет.

Второе следствие той же проблемы: rp_filter. Ядро по умолчанию отбрасывает пакеты, пришедшие «не с того» интерфейса, с точки зрения таблицы main. При двух аплинках это ровно наш случай. Либо net.ipv4.conf.all.rp_filter=2 (loose), либо готовьтесь долго смотреть в tcpdump и не понимать, куда деваются пакеты, которые точно приходят.

Как не отстрелить себе ssh

Отдельный абзац, оплаченный поездкой через весь город к роутеру, до которого больше не достучаться.

Ситуация: вы сидите по ssh, применяете правило, которое заворачивает вашу же подсеть в другой канал. Ваша ssh-сессия — часть этой подсети. Ответы роутера уходят в другой аплинк, сессия умирает вместе с вашей возможностью откатить правило.

Три меры, каждая из которых спасала:

Первая — исключить трафик, адресованный самому роутеру, до всякой маркировки:

iifname "br-lan" ip daddr 192.168.1.1 accept

Вторая — не трогать established: правило ct mark != 0x0 ... accept сверху как раз это и делает — существующие соединения продолжают ходить как ходили.

Третья, главная — не полагаться на первые две:

sleep 300 && /etc/init.d/firewall restart &

Запускаем перед тем, как применять что-то страшное. Если через пять минут вы всё ещё живы и довольны — убиваете таймер. Если нет — конфиг откатится сам. В OpenWrt для этого есть штатный механизм: luci-reload с подтверждением, но sleep && restart честнее и работает всегда.

Что вышло

Проверка — с нужной машины смотрим, каким IP мы наружу:

# с ноута
$ curl -s ifconfig.me
81.x.x.x        # кабель

# с телевизора (через ssh на роутер, -I br-lan.10)
$ curl -s --interface 192.168.10.5 ifconfig.me
100.x.x.x       # LTE, как и хотели

Работает уже полгода. Гостевой wifi и телевизор не мешают рабочему каналу, при падении кабеля можно одной строчкой перекинуть всех на LTE, а стенд ходит куда ему нужно и никого не трогает.

Всё это, конечно, умеет mwan3 — штатный пакет OpenWrt, с failover, health-check и веб-мордой. Я его сначала и поставил. Снёс через неделю: он делает то же самое, но пятью слоями абстракции поверх тех же ip rule, и когда что-то не работает — а не работает всегда — отлаживать приходится всё равно на уровне правил. Проще написать двадцать строк и понимать каждую.

← все заметки