Автоматизируем (частично) создание виртуальных машин qemu через systemd.

ТЗ:

  1. На железном сервере должны подниматься VM для работы пользователей.

  2. Интернет-трафик с каждой VM должен заворачиваться в свой socks5-прокси (весь!)

  3. Все это должно работать достаточно просто чтобы с этим справился обезьян*.

    *- мне не нравится слово эникей, ничего не имею против эникеев, да и справиться должен мой друг. Да и все мы, гоминиды, не так уж далеко от обезьян утопали.

Это конечно же не всё ТЗ, но про реализацию конкретно этих трех пунктов в части VM и будет данный пост.

Ни с написанием своих юнитов systemd, ни с qemu, ни с nftables я до этого не сталкивался, так что если вдруг что сделано не совсем правильно, извините.

Сетевое взаимодействие VM и хоста

Схема связи VM и хоста.
Схема связи VM и хоста.

При включении VM создается общий с хостом TAP интерфейс, на который со стороны клиента по dhcp задается IP, шлюз и DNS, а со стороны хоста этому же интерфейсу задается IP шлюза.

Весь входящий трафик на данном интерфейсе со стороны хоста заворачивается через tproxy на приложение с поддержкой tproxy


nftables

Из всего что мне приходилось изучать за последние несколько лет, nftables наверное самое "прямолинейное".

Cервер принимает ssh, http и https из интернета, а с других интерфейсов: 1080 TCP/UDP, 67 UDP, 53 UDP, 80 TCP и 1688 TCP

Дальше VM будут дописывать\удалять свои правила сами (точнее соответствующий systemd-юнит). Точнее это будет делать systemd

nftabables.conf:

Скрытый текст
#!/usr/sbin/nft -f

flush ruleset

define WAN = eno1

table inet main {

        set DROP_IP4 {
                typeof ip saddr
                flags interval
                auto-merge
                elements = {
                        127.0.0.0/8,
                        10.0.0.0/8,
                        172.16.0.0/12,
                        192.168.0.0/16,
                        100.64.0.0/10,
                        169.254.0.0/16,
                        192.0.0.0/24,
                        224.0.0.0/4,
                        240.0.0.0/4,
                        255.255.255.255/32
                }
        }

        set DROP_IP6 {
                typeof ip6 saddr
                flags interval
                auto-merge
                elements = {
                        ::/128,
                        ::1/128,
                        ::ffff:0:0/96,
                        ::/96,
                        100::/64,
                        2001:10::/28,
                        3fff::/20,
                        fc00::/7,
                        fe80::/10,
                        ff00::/8
                }
        }

        chain input_v4 {
                iif $WAN ip saddr @DROP_IP4 drop
                iif $WAN meta l4proto { tcp, udp } th dport { 22,443 } accept
                iif $WAN tcp dport 80 accept
                iif != $WAN udp dport { 67, 53, 1080 } accept
                iif != $WAN tcp dport { 80, 1080, 1688 } accept
        }

        chain input_v6 {
                icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
                iif $WAN drop
        }

        chain output_v4 {
                iif $WAN ip daddr @DROP_IP4 drop
        }

        chain output_v6 {
                iif $WAN ip6 daddr @DROP_IP6 drop
        }

        chain input {
                type filter hook input priority 0; policy drop;

                        ct state vmap { established : accept, related : accept, invalid : drop }
                        iif lo accept
                        meta mark 1 accept
                        meta protocol vmap { ip : jump input_v4, ip6 : jump input_v6 }
        }

        chain output {
                type filter hook output priority 0; policy accept;

                        iif lo accept
                        meta protocol vmap { ip : jump output_v4, ip6 : jump output_v6 }
        }
}

systemd

systemd у нас по плану спавнит инстансы qemu-kvm с нужными параметрами, которые берет из шаблона.

cat /etc/vm/template

id=0
cpu=6
memory=24G
proxy=127.0.0.1:10001

## доп аргументы для qemu-kvm, если надо.
#extra=""

## подключает VNC консоль. для отключения закомментировать (#)
#vncargs="-vnc 127.0.0.1:0 -usb -device usb-mouse -device usb-tablet -device usb-kbd"

## монтирует cdrom и "флешку" с тулзами. для отключения закомментировать (#)
#cdrom="-drive file=/opt/disks/myos-x64.iso,format=raw,if=none,media=cdrom,id=installcd,readonly=on -device ahci,id=ahci0 -device ide-cd,bus=ahci0.0,drive=installcd,id=toolscd,bootindex=1 -device u

id - уникальный идентификатор VM. К сожалению был нужен чтобы генерировать для vm и "шлюза" уникальные MAC-адреса.

cpu - кол-во ядер

memory - память

proxy - порт куда перенаправлять трафик вм через tproxy

extra - доп опции, которые передадутся исполняемому файлу qemu-kvm

vncargs - блок для подключения VNC (на случай экстренной необходимости подключиться через консоль

cdrom - блок для подключения к VM сидюка, флешки с драйверами qemu и целого зоопарка компонентов для того чтобы этот сидюк и флешку было куда втыкать (диск подключен через virtio-blk-pci, а он сидюки не поддерживает)

cat /etc/systemd/system/qemu@.service

Скрытый текст
[Unit]
Description=QEMU (%I)
After=network.service

[Service]
EnvironmentFile=/etc/vm/%i
ExecStartPre=-/bin/sh -c 'if ip link show tap${id} &> /dev/null; then /sbin/ip link delete dev tap${id}; fi'
ExecStartPre=-/bin/sh -c '/usr/bin/kill -TERM $(cat /run/dnsmasq-%i.pid) > /dev/null || /bin/true'
ExecStartPre=-/bin/sh -c "/usr/bin/kill $( /usr/bin/ps aux | /usr/bin/grep [q]emu-kvm  | /usr/bin/grep %i  | /usr/bin/awk '{print $2}') > /dev/null || /bin/true"
ExecStartPre=/usr/bin/sleep 4
ExecStart=/bin/sh -c '/usr/libexec/qemu-kvm -enable-kvm -machine type=q35,accel=kvm -cpu host -smp ${cpu} -m ${memory} -netdev tap,id=tap${id},ifname=tap${id},script=no,downscript=no -device virtio-net,netdev=tap${id},mac=$( /bin/macgen.sh ${id} ) -drive if=none,id=%i,file=/opt/disks/%i.img,format=raw -device virtio-blk-pci,drive=%i -display none -monitor unix:/run/qemu-%i-monitor,server,nowait $vncargs $extra $cdrom'
ExecStartPost=/usr/bin/sleep 4
ExecStartPost=/bin/sh -c '/sbin/ip link set dev tap${id} address $( /bin/macgen.sh -${id} )'
ExecStartPost=/bin/sh -c '/sbin/ip link set up tap${id}'
ExecStartPost=/bin/sh -c '/sbin/ip addr add 10.0.${id}.1/30 dev tap${id}'
ExecStartPost=/bin/sh -c '/sbin/nft add table inet tap${id}'
ExecStartPost=/bin/sh -c '/sbin/nft add chain inet tap${id} prerouting "{type filter hook prerouting priority mangle; policy accept;}"'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip saddr 10.0.${id}.2 ip daddr 10.0.${id}.1 return'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 255.255.255.255 udp dport 67 return'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip saddr 10.0.${id}.1 ip daddr 10.0.${id}.2 return'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} udp dport 53 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} udp dport 443 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 10.0.0.0/8 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 192.168.0.0/16 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 172.16.0.0/12 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 100.64.0.0/10 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 169.254.0.0/16 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip daddr 224.0.0.0/3 drop'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip protocol tcp tproxy ip to ${proxy} meta mark set 1'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} ip protocol udp tproxy ip to ${proxy} meta mark set 1'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} meta mark 1 return'
ExecStartPost=/bin/sh -c '/sbin/nft add rule inet tap${id} prerouting iifname tap${id} drop'
ExecStartPost=/bin/sh -c '/sbin/dnsmasq --interface=tap${id} --except-interface=lo --bind-interfaces --dhcp-range=10.0.${id}.2,10.0.${id}.2,24h --dhcp-option=6,10.0.${id}.1  --dhcp-host=$( /bin/macgen.sh ${id} ),10.0.${id}.2 --port=53 --server=127.0.0.1 --pid-file=/run/dnsmasq-%i.pid --address=/wpad.void.loc/10.0.${id}.1 --leasefile-ro --dhcp-option=option:domain-name,void.loc --srv-host=_vlmcs._tcp.void.loc,wpad.void.loc,1688,0,100 --no-resolv'
ExecStop=-/bin/sh -c 'if [ -f "/run/qemu-%i-monitor" ]; then /usr/bin/echo system_powerdown | /usr/bin/nc -U /run/qemu-%i-monitor || /bin/true; fi'
ExecStop=-/bin/sh -c '/usr/bin/kill -INT $MAINPID &> /dev/null || /bin/true'
ExecStop=/bin/sh -c '/sbin/nft delete table inet tap${id} &> /dev/null || /bin/true'
ExecStop=-/bin/sh -c 'if [ -f "/run/dnsmasq-%i.pid" ]; then /usr/bin/kill -TERM $(cat /run/dnsmasq-%i.pid) && /bin/rm /run/dnsmasq-%i.pid; fi'
TimeoutStopSec=90
KillMode=none
Restart=on-success

[Install]
WantedBy=multi-user.target

Как можно увидеть из текста этого незамысловатого systemd-сервиса, спавнер берет параметры из одноименного конкретному инстансу сервиса шаблона и спавнит VM с некоторыми доп опциями:

ID - участвует в имени tap интерфейса, в название таблицы nftables для данной VM и в третьем октете сети, которая будет выделена для tap интерфейса VM и еще кое-где.

Т.е. для ID=0 это будет tap0 интерфейс, tap0 таблица в nftables и 10.0.0.0 подсеть.

для ID=1 соответственно tap1, 10.0.1.0 и т.п.

Еще ID используется для генерации двух MAC-адресов для tap интерфейса (один со стороны VM чтобы статику выдавать по DHCP, один со стороны хоста чтобы у VM после каждой перезагрузки не плодились СетьN из-за смены адреса у шлюза.

Скрипт для генерации маков не мой - нашел в интернете. На момент написания статьи к сожалению не смог нагуглить где я его когда-то нашел, вроде бы где-то на stackoverflow

cat /bin/macgen.sh

#!/bin/bash
/usr/bin/echo $1 | /bin/md5sum | /bin/sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/'

Скрипту скармливается ID и -ID и получаются статичные уникальные пары маков для каждой VM

Еще сервис прописывает в nftables правила для tap0 интерфейса.

Итоговая таблица для tap0 выглядит вот так.

table inet tap0 {
        chain prerouting {
                type filter hook prerouting priority mangle; policy accept;
                iifname "tap0" ip saddr 10.0.0.2 ip daddr 10.0.0.1 return
                iifname "tap0" ip daddr 255.255.255.255 udp dport 67 return
                iifname "tap0" ip saddr 10.0.0.1 ip daddr 10.0.0.2 return
                iifname "tap0" udp dport 53 drop
                iifname "tap0" udp dport 443 drop
                iifname "tap0" ip daddr 10.0.0.0/8 drop
                iifname "tap0" ip daddr 192.168.0.0/16 drop
                iifname "tap0" ip daddr 172.16.0.0/12 drop
                iifname "tap0" ip daddr 100.64.0.0/10 drop
                iifname "tap0" ip daddr 169.254.0.0/16 drop
                iifname "tap0" ip daddr 224.0.0.0/3 drop
                iifname "tap0" ip protocol tcp tproxy ip to 127.0.0.1:10001 meta mark set 0x00000001
                iifname "tap0" ip protocol udp tproxy ip to 127.0.0.1:10001 meta mark set 0x00000001
                iifname "tap0" meta mark 0x00000001 return
                iifname "tap0" drop
        }
}

Ну и всякий доп обвес в виде выключения\удаления всего этого добра, когда systemd служба виртуальной машины останавливается.

Для удобства настройки VM запускается dns и dhcp dnsmasq с привязкой к нужному интерфейсу.

Гостевая ОС

Дабы нашему обезьянычу не пришлось при деплое VM каждый раз лезть в конфиги, включать vnc и ковыряться с настройкой, шаблон диска для VM - сиспрепнутый Windows с заранее созданным юзером (generalize тем не менее был!) и с вот таким вот unattended.xml

<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
	<settings pass="offlineServicing"></settings>
	<settings pass="windowsPE">
		<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
			<UserData>
				<ProductKey>
					<Key>00000-00000-00000-00000-00000</Key>
					<WillShowUI>Always</WillShowUI>
				</ProductKey>
				<AcceptEula>true</AcceptEula>
			</UserData>
			<UseConfigurationSet>false</UseConfigurationSet>
		</component>
	</settings>
	<settings pass="generalize"></settings>
	<settings pass="specialize"></settings>
	<settings pass="auditSystem"></settings>
	<settings pass="auditUser"></settings>
	<settings pass="oobeSystem">
                <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
                         <InputLocale>ru-ru</InputLocale>
                         <SystemLocale>ru-ru</SystemLocale>
                         <UILanguage>ru-ru</UILanguage>
                         <UILanguageFallback>ru-ru</UILanguageFallback>
                         <UserLocale>ru-ru</UserLocale>
                </component>
		<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
                         <OOBE>
                                 <VMModeOptimizations>
                                    <SkipAdministratorProfileRemoval>true</SkipAdministratorProfileRemoval>
                                 </VMModeOptimizations>
                                 <HideEULAPage>true</HideEULAPage>
                                 <HideLocalAccountScreen>true</HideLocalAccountScreen>
                                 <skipUserOobe>true</skipUserOobe>
                                 <skipMachineOobe>true</skipMachineOobe>
                                 <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
                                 <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
                                 <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
                                 <ProtectYourPC>1</ProtectYourPC>
                                 <UnattendEnableRetailDemo>false</UnattendEnableRetailDemo>
                         </OOBE>
		</component>
	</settings>
</unattend>

Шпаргалка по настройке

  • ID в шаблоне должен быть уникальным

  • название диска (без .img) должно соответствовать названию шаблона systemd-юнита

Копируем образ диска из шаблонного template.img и шаблон юзита

cp /opt/disks/template.img /opt/disks/vm123.img
cp /etc/vm/template /etc/vm/vm123

меняем в шаблоне ID и адрес прокси (процессор\память по желанию)

nano /etc/vm/vm123

если нужно увеличить диск, то сделать это можно так (в примере размер увеличивается на 5гб)

truncate -s +5G /opt/disks/vm123.img

когда все подготовлено включить автозапуск и запустить VM можно так

systemctl enable --now qemu@vm123

P.S.

Этот еще вместо технической документации будет. Решил пока писал.

Комментарии (0)