Безопасный доступ к умному дому при отсутствии публичного IP (часть 2)

    Вступление


    В первой части я писал о постановке задачи и как трансформировались хотелки. В итоге я решил использовать 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 у меня дома. И даже если такое произойдет, это скорее станет известным, чем если ломанут меня лично.

    Все красиво, работает без сбоев, изменения при необходимости внести легко, лишних серверов, за которыми нужен уход, не появилось, я доволен.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 8

      +2

      wireguard решает 99% всех проблем, с которыми вы боролись. wg поддерживает перенос wg-интерфейса в namespace, что позволяет его инжектить в контейнер (по сути, network namespace) без каких-либо выкрутас на хосте.


      Внешний трафик (после шифрования) при этом продолжает ходить с интрефейса, в котором wg создавали (т.е. вне контейнера).


      Это одна из wg-специфичных фич, за который мы его любим.

        0
        Во-первых, я просто не ожидал, что с OpenVPN столкнусь с такими заморочками, не знал современных трендов. Теперь лучше понимаю особенности, которые добавляют контейнеры.

        А во-вторых, когда уже начал и столкнулся с трудностями, первая ссылка по «wireguard container VPS» была:
        I've got Wireguard running on my home network and also on my free tier Google Cloud VPS/VM, both via Docker container (using the cmulk/wireguard-docker image — github.com/cmulk/wireguard-docker). I can successfully connect to them from my Windows 10 laptop and Android phone. Now I want to set it up so that my home network and VPS are always connected to each other as if they're local (i.e., site to site VPN). I thought simply adding each as peers in each wg0.conf file and restarting the containers would do the trick, but I was dead wrong. How do I do site to site VPN with Wireguard running in containers?

        Решил, что тоже есть особенности, и не стал менять шило на мыло.

        Но рано или пооздно wg попробую, безусловно. Похоже, настает его время.
          0
          На всякий случай на будущее, в managed kubernetes (если я когда-нибудь соберусь запустить контейнер на, к примеру, AWS Fargate), wg тоже поддерживается (пусть и без ускорения за счет ядра)?
            0

            Как не сотрудник службы поддержки AWS, отвечаю: не имею ни малейшего представления.


            WG в апстриме linux-5.6, а что там хостеры юзают — это их спрашивать надо.

            0
            Можно все-таки уточнить? Он может работать «сам по себе»? Или обязательно нужна поддержка в ядре и всякие разные требования к серверу?

            Я взял самый популярный контейнер с докерхаба, по инструкции запустил
            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

              0

              (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:


              1. Setup tunnel on host system.
              2. Move wg0 into network namespace.

              For old kernels you need dkms. Modern kernels (5.6+) does not need it anymore.

            0

            Streisand + wireguard в кач-ве vpn-сервера можно использовать AWS. Дома на сервере в докер-контейнере traefik отвечает за маршрутизацию к внутренним ресурсам.
            Пользуюсь уже некоторое время, проблем никаких нет.
            У Вас, в статье все несколько усложнено.

              0
              «несколько усложнено» — это мягко сказано :)
              Это я так шел.
              В итоге расценил такое кривым и отказался. В качестве полезного опыта вынес ряд моментов, которые могут оказаться полезными на будущее.

              Например, обнаружить, что после iptables -I INPUT -p tcp -m tcp --dport 1883 -j DROP порт остается доступным из Интернета — для меня это серьезно. Не думаю, что прям вот все это знают, один я лажанулся, поэтому и записал.

            Only users with full accounts can post comments. Log in, please.