
Автоматизируем (частично) создание виртуальных машин qemu через systemd.
ТЗ:
На железном сервере должны подниматься VM для работы пользователей.
Интернет-трафик с каждой VM должен заворачиваться в свой socks5-прокси (весь!)
-
Все это должно работать достаточно просто чтобы с этим справился обезьян*.
*- мне не нравится слово эникей, ничего не имею против эникеев, да и справиться должен мой друг. Да и все мы, гоминиды, не так уж далеко от обезьян утопали.
Это конечно же не всё ТЗ, но про реализацию конкретно этих трех пунктов в части VM и будет данный пост.
Ни с написанием своих юнитов systemd, ни с qemu, ни с nftables я до этого не сталкивался, так что если вдруг что сделано не совсем правильно, извините.
Сетевое взаимодействие 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.
Этот еще вместо технической документации будет. Решил пока писал.