Вступление
В первой части я писал о постановке задачи и как трансформировались хотелки. В итоге я решил использовать OpenVPN, но, всвязи с тем, что решил все запускать в Docker контейнерах, это получилось не так-то просто.
Сразу скажу, что потом я опять все переделал, в итоге отказался от внешнего VPS. Однако, поскольку все в контейнерах, в процессе столкнулся с рядом интересных особенностях, о коих и пойдет речь.
Установка
Опишу только ключевые моменты.
ioBroker
docker run -d --name iobhost --net=host -v /opt/iobroker/:/opt/iobroker/ --device=/dev/ttyACM0 --env-file /opt/ioBroker_env.list --restart=always buanet/iobroker:latest
Поскольку у меня есть MiHome Gateway, к нему подключены датчики, даже настроено несколько сценариев, которые я не хочу пока ломать, я подключил к нему ioBroker. Он увидел датчики, не пришлось их перепривязывать к Zigbee stick (хотя тоже есть, и кое-какие кнопки подключены к нему).
Вот для того, чтобы ioBroker связался с MiHome Gateway, и пршлось запускать его с параметром --net=host. Т.е. он использует интерфейс хоста, указывать, какие порты пробрасывать в контейнер не нужно. Без этого он шлюза не видел, ибо тот работает через мультикасты.
Параметр --device=/dev/ttyACM0 нужен для проброса Zigbee USB stick в контейнер. Также в /opt/ioBroker_env.list пришлось добавить строчку USBDEVICES="/dev/ttyACM0". Важно, что эта строчка должна присутствовать в момент первого запуска контейнера, когда он видит пустую директорию и начинает свою первичную настройку.
Можно и потом настроить, конечно, но прридется делать дополнительные телодвижения.
MQTT сервер
На внешнем VPS запустил eclipse mosquito. Сначала настроил ему TLS, выписав сертификат Let’s encrypt. Потом решил, что клиенты должны обязательно предъявить сертификат, и только потом уже имя и пароль (защита от bruteforce). Так что переделал на самоподписанный, чтобы клиентам выписывать сертификаты.
OpenVPN
Использовал популярные образы с Docker Hub. Для сервера (VPS) kylemanna/openvpn. Для клиента (домашний сервер, где установлен ioBroker) — ekristen/openvpn-client.
Настройка
Вот здесь пришлось повозиться. В процессе хорошо прочувствовал сетевые аспекты докера, работа с iptables, узанл новое, в т.ч. netplan, с которым раньше дела не имел. Собственно поэтому и решил написать эту статью.
Сервер VPS
С установкой OpenVPN и настройкой все стандартно, как на https://hub.docker.com/r/kylemanna/openvpn написано.
Что сделал дополнительно, так это поднял дополнительные loopback на VPS и домашнем сервере.
/etc/netplan/01-netcfg.yaml
# This file describes the network interfaces available on your system
# For more information, see netplan(5).
network:
version: 2
renderer: networkd
<b>ethernets:
lo:
renderer: networkd
match:
name: lo
addresses:
- 192.168.16.1/32</b>
Жирным выделил «добавку». Пробелы, похоже, не отображаются, хотя они очень важны.
Адреса в примерах буду использовать 192.168.6.0 для домашней сети и 192.168.16.0 для loopback. Постараюсь нигде не ошибиться.
В openvpn.conf добавил
server 192.168.16.192 255.255.255.192
push "dhcp-option DNS 192.168.16.6"
push "route 192.168.16.0 255.255.255.128"
client-to-client
client-config-dir /etc/openvpn/ccd/
Специально сделал loopback из 192.168.16.0/25, а выдаю адреса клиентам из 192.168.16.128/25, чтобы впоследствии ращзрешающие правила в iptables настраивать одной сеткой 192.168.16.0/24
Итак, у VPS loopback 192.168.16.1, на нем же mqtt.
У домашнего сервера 192.168.16.6. Там же iobroker, он же dns для домашних клентов и переопределяет ряд доменных имен для подключающихся из домашней сети или через VPN.
Была мысль везде прописать его «реальный» IP из сети. Типа в ccd/iobroker указать
iroute 192.168.6.6 255.255.255.255
Но, видимо от того, что это адрес из сети на WiFi интерфейсе домашнего ноутбука, да еще является во многих случаях шлюзом по умолчанию, были глюки. В том числе со смартфоном. А мне хотелось, чтобы из домашней сети работало как с активным клиентом, так и без него. И не приходилось судорожно отключать клиента, придя домой, чтобы получить доступ к другим ресурсам.
Поэтому настроил, чтобы этот сервер и его контейнеры всегда взаимодействовали с loopback, независимо от того, активен ли VPN. И к этому же адресу шло обращение.
Так что в ccd/iobroker
iroute 192.168.16.6 255.255.255.255
Хорошо, все VPN клиенты знают, что сеть 192.168.16.0/24 доступна через VPN. Если они шлют пакеты на 192.168.16.1 (loopback VPS), пакет шифруется, попадает в контейнер openvpn, расшифровывается, по маршруту по умолчанию идет на 172.17.0.1 (default gateway в контейнерах по умолчанию), попадает на хост, все хорошо.
Но как мне с VPS хоста «пингануть» VPN клиента или обратиться к домашнему серверу с адресом 192.168.16.6 (а не его временному IP на VPN туннеле, который находится внутри контейнера OpenVPN)?
Очевидно, что сетку 192.168.16.0 надо направить в контейнер OpenVPN. Я, конечно, могу посмотреть, что он 172.17.0.3. Но в один прекрасный день может и поменяться.
Был бы OpenVPN развернут прямо на сервере, а не в контейнере, все заработало бы само. А тут пришлось делать хитро. Создаю скрипт, который отрабатывается самым последним в system, и в него помещаю:
ipaddr=$(docker inspect -f '{{.NetworkSettings.IPAddress}}' vpn-client)
route add -net 192.168.16.0/24 gw $ipaddr
Т.е. через docker inspect узнаю IP адрес запущенного контейнера, а потом на него маршрутизирую сетку обычным порядком.
В отличие от привычного rc.local пришлось погуглить, а как же, собственно, сделать скрипт, запускающийся последним. Приведу краткую инструкцию:
Создать файл /etc/systemd/system/custom.target:
[Unit]
Description=My Custom Target
Requires=multi-user.target
After=multi-user.target
AllowIsolate=yes
Создать файл /etc/systemd/system/last_command.service
[Unit]
Description=My custom command
After=multi-user.target
[Service]
Type=simple
ExecStart=/usr/local/bin/my_last_command.sh
[Install]
WantedBy=custom.target
Создать директорию /etc/systemd/system/custom.target.wants
ln -s /etc/systemd/system/last_command.service \ /etc/systemd/system/custom.target.wants/last_command.service
systemctl daemon-reload
systemctl set-default custom.target
При желании запустить сразу, не дожидаясь перезагрузки: systemctl isolate custom.target
Вот теперь после перезагрузки последним будет запускаться файл /usr/local/bin/my_last_command.sh, описанный в ExecStart.
Iptables
На mqtt сервере у меня поднято 2 порта: 8883 с TLS и аутентификацией, доступен из Интернета для удаленных датчиков. Да и сам могу каким-нибудь MQTT Explorer подключиться и проверить, что и как.
1883 уже без TLS, требует только имя и пароль. Нужен для домашнего Sonoff rfBridge, который TLS не умеет.
Это не страшно, поскольку из дома трафик пойдет на сервер с iobroker, который является шлюзом по умолчанию для 192.168.16.0, он перешлет пакет в контейнер с OpenVPN и т.п. Однако необходимо разрешить доступ к порту 1883 только «изнутри». Т.е. iptables.
Стандартный подход – запретить доступ к этому порту отовсюду, потом выполнить правило, разрешающее доступ из внутренних сетей.
iptables -I INPUT -p tcp -m tcp --dport 1883 -j DROP
iptables -I INPUT -s 172.17.0.0/24 -p tcp -m tcp --dport 1883 -j ACCEPT
Сетка здесь указана 172.17.0.0, поскольку из OpenVPN «к соседям» я в итоге хожу как hide NAT (что контейнер iobroker что rfBridge из WiFi сети), а не с оригинальных.
И так работает. Но есть нюанс. У меня порт 1883 проброшен в контейнер mqtt. И, как оказалось, iptables сначала отрабатывает цепочку DOCKER-USER.
Т.е при таком правиле доступ к порту 1883 разрешался _до_ блокирующего правила. И из Интернета к нему тоже можно было спокойно подключиться.
Блокирующее правило надо создавать в цепочке DOCKER-USER!
iptables -I DOCKR-USER -p tcp -m tcp --dport 1883 -j DROP
iptables -I INPUT -s 172.17.0.0/24 -p tcp -m tcp --dport 1883 -j ACCEPT
А вот нижнее, разрешающее доступ из внутренних сетей, почему-то требует INPUT.
Домашний сервер
Основная часть такая же. Однако он хоть и клиент, но должен был маршрутизировать трафик из WiFi сети (rfBridge) в mqtt на VPS. Т.е. движение трафика:
rfBridge (192.168.6.8) -> iobroker host (192.168.6.6) -> контейнер vpn-client (172.17.0.?) -> контейнер opevpn на VPS -> loopback VPS (192.168.16.1) -> контейнер mqtt (порт 1883)
Разрешаем «форвардить» пакеты для сетки с клиентами и лупбэками:
iptables -A FORWARD -d 192.168.16.0/24 -j ACCEPT
и переходим к контейнерной специфике.
Задача 1
Как вы помните, взаимодействие между подсистемами у меня настроено через loopback. Т.е. нужно, чтобы пакеты на 192.168.16.6 из vpn-clientушли на хост (172.17.0.1), а не в VPN туннель.
Если в запущенном контейнере выполнить такую команду, все заработает. После перезагрузки это забудется, но в конфигурационном файле iobroker.ovpn можно указать
route 192.168.16.6 255.255.255.255 172.17.0.1
.И openvpn этот маршурт будет устанавливать при старте контейнера. Это решено легко стандартным способом.
Задача 2
Пакеты из домашней сети 192.168.6.0 (например, от rfBridge) попадают на хость iobroker и форвадятся в контейнер vpn-client.
Однако сеть 192.168.6.0 я умышленно не включаю в домен шифрования, контейнер OpenVPN на VPS не знает, что с этой сеткой делать. Очевидное решение – сделать NAT внутри vpn-client, чтобы пакет на VPS пришел с его адреса. Но есть нюанс. Как сохранить требуемые команды iptables после рестарта контейнера? Iptables-persistent туда так просто не поставишь.
Можно, конечно, собрать новый контейнер с добавками. Но не хочется, ибо усложнится процедура апгрейда. Вместо «убил и запустил latest, а конфигурацию он подтянул из подмонтированной папки» нужно будет запускать сборку… Не для того я с контейнерами связываюсь.
Поэтому решил «после старта контейнера принудительно выполнять в нем команды, указывающие iptables делать NAT». Для этого воспользовался командой
docker events --filter "container=vpn-client" --filter "event=start"
.Она висит и ждет события, заданного в фильтрах. В моем случае старта контейнера. После чего через docker exec выполняю в нем с хоста требуемые команды.
Для этого по аналогии с VPS настраиваю /usr/local/bin/my_last_command.sh
#!/bin/bash
cont="vpn-client"
ipaddr=$(docker inspect -f '{{.NetworkSettings.IPAddress}}' $cont)
route add -net 192.168.16.0/24 gw $ipaddr
for (( ; ; ))
do
docker events --filter "container=$cont" --filter "event=start"
docker exec -it $cont iptables -t nat -I POSTROUTING -s 192.168.16.0/255.255.255.0 -j MASQUERADE
docker exec -it $cont iptables -t nat -I POSTROUTING -s 192.168.6.0/255.255.255.0 -j MASQUERADE
done
Есть примеры, в которых вывод docker events передается на вход awk, который выполняет команды. Но мне показалось проще «повисеть» до наступления события, выполнить команды, и опять ждать события.
Заключение
Честно говоря, мне не хотелось писать этот пост. Интересный опыт приобрел, но «не красиво» получилось, слишком сложно, я так не люлю. Так что я опять все переделал, отказался от VPS вообще. Но раз уж обещал вторую часть … Кроме того, меня впечатлил подход с docker events, захотелось им поделиться. Думаю, он еще пригодится.
В итоге я решил так.
Коль скоро мне не удалось опубликовать vis через reverse proxy, а mqtt я запросто могу взять «извне» как сервис, VPS для этой задачи мне и не нужен. Выкладывать прошивки для обновления по OTA я могу и на хостинг, благо тоже есть.
Поэтому.
Mqtt взял на wqtt.ru. TLS есть (пароли шлются в защищенном виде). Скорость прекрасная (10ms против 80ms у mymqtthub). Топики переписывать на '$device/<безумный ID>/events' (как у Яндекса) не требуется. Т.е. в случае чего перескочить куда-то можно элементарно. Цена копеечная (300 руб в год).
Firmware для OTA выкладываю на имеющийся на хостинг.
Доступ к vis – все-таки через Zerotier. Уж очень просто и удобно. А если их и сломают, так вряд ли ради посмотреть уровень CO2 у меня дома. И даже если такое произойдет, это скорее станет известным, чем если ломанут меня лично.
Все красиво, работает без сбоев, изменения при необходимости внести легко, лишних серверов, за которыми нужен уход, не появилось, я доволен.
amarao
wireguard решает 99% всех проблем, с которыми вы боролись. wg поддерживает перенос wg-интерфейса в namespace, что позволяет его инжектить в контейнер (по сути, network namespace) без каких-либо выкрутас на хосте.
Внешний трафик (после шифрования) при этом продолжает ходить с интрефейса, в котором wg создавали (т.е. вне контейнера).
Это одна из wg-специфичных фич, за который мы его любим.
Anrikigai Автор
Во-первых, я просто не ожидал, что с OpenVPN столкнусь с такими заморочками, не знал современных трендов. Теперь лучше понимаю особенности, которые добавляют контейнеры.
А во-вторых, когда уже начал и столкнулся с трудностями, первая ссылка по «wireguard container VPS» была:
Решил, что тоже есть особенности, и не стал менять шило на мыло.
Но рано или пооздно wg попробую, безусловно. Похоже, настает его время.
Anrikigai Автор
На всякий случай на будущее, в managed kubernetes (если я когда-нибудь соберусь запустить контейнер на, к примеру, AWS Fargate), wg тоже поддерживается (пусть и без ускорения за счет ядра)?
amarao
Как не сотрудник службы поддержки AWS, отвечаю: не имею ни малейшего представления.
WG в апстриме linux-5.6, а что там хостеры юзают — это их спрашивать надо.
Anrikigai Автор
Можно все-таки уточнить? Он может работать «сам по себе»? Или обязательно нужна поддержка в ядре и всякие разные требования к серверу?
Я взял самый популярный контейнер с докерхаба, по инструкции запустил
docker run -it --rm --cap-add sys_module -v /lib/modules:/lib/modules cmulk/wireguard-docker:buster install-module
Система начала устанавливать адовую кучу пакетов (вообще-то я контейнер использую, чтобы не трогать хостовую ОС, ну да ладно). В результате насыпалось сообщений, которые мне не очень понравились:
E: Unable to locate package linux-headers-4.15.0-99-generic
E: Couldn't find any package by glob 'linux-headers-4.15.0-99-generic'
E: Couldn't find any package by regex 'linux-headers-4.15.0-99-generic'
debconf: unable to initialize frontend: Dialog
debconf: (No usable dialog-like program is installed, so the dialog based frontend cannot be used. at /usr/share/perl5/Debconf/FrontEnd/Dialog.pm line 76.)
Setting up wireguard-dkms (1.0.20200506-1~bpo10+1) …
Loading new wireguard-1.0.20200506 DKMS files...
It is likely that 4.15.0-99-generic belongs to a chroot's host
Building for 4.15.0-99-generic
Module build for kernel 4.15.0-99-generic was skipped since the
kernel headers for this kernel does not seem to be installed.
Processing triggers for libc-bin (2.28-10) ...
Делал это и как обычно, через sudo. И непосредственно из под рута.
#docker run --cap-add net_admin --cap-add sys_module -v /opt/wireguard:/etc/wireguard -p 5555:5555/udp cmulk/wireguard-docker:buster
[#] ip link add wg0 type wireguard
RTNETLINK answers: Operation not supported
Unable to access interface: Protocol not supported
[#] ip link delete dev wg0
Cannot find device "wg0"
Adding iptables NAT rule
Стоит разбираться? Или VPS не поддерживает?
Ядро свежее
# uname -s -v -r
Linux 4.15.0-99-generic #100-Ubuntu SMP Wed Apr 22 20:32:56 UTC 2020
amarao
(I've broke my Russian keyboard for now).
You don't need to install kernel modules (or userspace) into containers. WG should be managed outside of containers. Move a wg0 device (or wgX, whatever you'll get after creation of conneciton) into namespace.
Basically, you:
For old kernels you need dkms. Modern kernels (5.6+) does not need it anymore.