aetherscale

[unmaintained] code for a cloud provider tutorial
Log | Files | Refs | README | LICENSE

commit d26e91d145bfbb3bac8afd68bd348bbec35c90b7
parent 98f88101704d15bf806e528799bf04a0be20cf88
Author: Stefan Koch <programming@stefan-koch.name>
Date:   Tue,  5 Jan 2021 19:32:00 +0100

automatically create network devices on service start

Diffstat:
MREADME.md | 59++++++++++++++++++++++++++++++++++-------------------------
Maetherscale/computing.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Maetherscale/config.py | 6++++++
Maetherscale/execution.py | 4++--
Maetherscale/networking.py | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Maetherscale/vpn/tinc.py | 17++++++++++++++++-
Dbin/setup-tap-vde.sh | 49-------------------------------------------------
Mtests/test_networking.py | 29+++++++++++++++++++++++++++++
8 files changed, 217 insertions(+), 119 deletions(-)

diff --git a/README.md b/README.md @@ -12,13 +12,14 @@ which I am currently writing. ## Installation -You can install the package with: +I recommend that you install the package in a virtual environment for +easy removal and to avoid conficts with system-wide stuff: ```bash git clone https://github.com/aufziehvogel/aetherscale cd aetherscale virtualenv venv && source venv/bin/activate -pip install -e . +pip install . ``` ### Operating System Changes @@ -29,33 +30,26 @@ For some actions more permissions than a standard user usually has are needed, though. This section will guide you through all of the changes required to allow aetherscale itself to run as a standard user. -#### Bridge Networking +#### Networking -Before you can start using the server you need to setup a TAP device to which -VDE networking can connect. This is needed so that the started VMs can -join the network. To be able to create a TAP device that is connected to your -real network, you might also have to setup a software bridge. aetherscale -includes a script to help you with this. Since I could only test on my PC -it might require some adjustment on other PCs. It takes all required info -as parameters. +aetherscale has to adjust your networking in order to expose the VMs to the +network. I decided to setup a bridge network for the VMs to join with +`iproute2`. -```bash -bin/setup-tap-vde.sh -u USER -i IP_ADDRESS -g GATEWAY -e PHYSICAL_DEVICE - -# For example -bin/setup-tap-vde.sh -u username -i 192.168.0.10/24 -g 192.168.0.1 -e eth0 -``` +Bridge networks are used in two different situations: -#### VPN Networking +1. When exposing the VM to the public internet +2. When establishing a VPN network between multiple VMs -It's possible to connect different VMs on possibly different hosts with a VPN. -For each virtual network a tinc instance is started. +In both cases we use the `iproute2` (`ip`) utility. To allow aetherscale to +run as a non-root user while still having access to networking changes, I +decided to use `sudo` and allow rootless access to `ip`. -In order to allow dynamic creation of network device entries we use `sudo` -calls to the `ip` utility. To auto-configure IPv6 addresses of VPNs inside +For VPN there currently is one more change needed (but this is only +temporary). To auto-configure IPv6 addresses of VPNs inside the guest VM we use IPv6 Router Advertisement messages. We run a `radvd` server that sends out the prefixes for IPv6 addresses. `radvd` also requires -root permissions (or to be exact `CAP_NET_RAW` permissions). +root permissions (to be exact it requires `CAP_NET_RAW` permissions). To allow these calls you have to enable passwordless sudo permissions with the following entry in `visudo`: @@ -81,13 +75,28 @@ program, it feels too heavy for a proof-of-concept tool. ## Usage -The server can be started with: +To start the server you have to define your host's IP address and gateway. This +is needed, because aetherscale re-maps the IP configuration from your +physical interface to the newly created bridge device. + +For example, if your PC has the IP address `192.168.2.123` in a `/24` subnet, +the gateway is `192.168.2.1` and your ethernet device is `enp0s25`. ```bash -aetherscale +NETWORK_IP=192.168.2.123/24 NETWORK_GATEWAY=192.168.2.1 \ + NETWORK_PHYSICAL_DEVICE=enp0s25 aetherscale ``` -For example, to list all running VMs run the following client command: +Once the server is running, you can start a VM with: + +```bash +aetherscale-cli create-vm --image some-base-image-name +``` + +The base image must exist as `$BASE_IMAGE_FOLDER/some-base-image-name.qcow2`. +You can configure the environment variable `$BASE_IMAGE_FOLDER` to any folder. + +You can then list all running VMs with: ```bash aetherscale-cli list-vms diff --git a/aetherscale/computing.py b/aetherscale/computing.py @@ -11,15 +11,14 @@ import subprocess import sys import tempfile import time -from typing import List, Optional, Dict, Any, Callable +from typing import List, Optional, Dict, Any, Callable, Tuple from . import networking from .qemu import image, runtime from .qemu.exceptions import QemuException from . import config from . import services -from .vpn.tinc import TincVirtualNetwork, VpnException -from .execution import run_command_chain +from .vpn.tinc import TincVirtualNetwork import aetherscale.vpn.radvd @@ -66,6 +65,34 @@ def create_user_image(vm_id: str, image_name: str) -> Path: return user_image +def setup_script_path(tap_name: str) -> Path: + network_conf_dir = config.AETHERSCALE_CONFIG_DIR / 'networking' + return network_conf_dir / f'{tap_name}-setup.sh' + + +def teardown_script_path(tap_name: str) -> Path: + network_conf_dir = config.AETHERSCALE_CONFIG_DIR / 'networking' + return network_conf_dir / f'{tap_name}-teardown.sh' + + +def setup_tap_device(tap_name: str, bridge: str) -> Tuple[Path, Path]: + iproute = networking.Iproute2Network() + iproute.tap_device(tap_name, config.USER, bridge) + + setup_script = setup_script_path(tap_name) + teardown_script = teardown_script_path(tap_name) + + with open(setup_script, 'w') as f: + f.write(iproute.setup_script()) + os.chmod(setup_script, 0o755) + + with open(teardown_script, 'w') as f: + f.write(iproute.teardown_script()) + os.chmod(teardown_script, 0o755) + + return setup_script, teardown_script + + class ComputingHandler: def __init__( self, radvd: aetherscale.vpn.radvd.Radvd, @@ -124,9 +151,15 @@ class ComputingHandler: qemu_interfaces = [] + network_setup_scripts = [] + network_teardown_scripts = [] + if 'vpn' in options: # TODO: Do we have to assign the VPN mac addr to the macvtap? vpn_tap_device = self._establish_vpn(options['vpn'], vm_id) + network_setup_scripts.append(setup_script_path(vpn_tap_device)) + network_teardown_scripts.append( + teardown_script_path(vpn_tap_device)) mac_addr_vpn = networking.create_mac_address() logging.debug( @@ -143,19 +176,27 @@ class ComputingHandler: mac_addr = networking.create_mac_address() logging.debug(f'Assigning MAC address "{mac_addr}" to VM "{vm_id}"') + pub_tap_device = f'pub-{vm_id}' pubnet = runtime.QemuInterfaceConfig( mac_address=mac_addr, - type=runtime.QemuInterfaceType.VDE, - vde_folder=Path(VDE_FOLDER)) + type=runtime.QemuInterfaceType.TAP, + tap_device=pub_tap_device) qemu_interfaces.append(pubnet) + setup_script, teardown_script = setup_tap_device( + pub_tap_device, 'br0') + network_setup_scripts.append(setup_script) + network_teardown_scripts.append(teardown_script) + qemu_config = runtime.QemuStartupConfig( vm_id=vm_id, hda_image=user_image, interfaces=qemu_interfaces) unit_name = systemd_unit_name_for_vm(vm_id) - self._create_qemu_systemd_unit(unit_name, qemu_config) + self._create_qemu_systemd_unit( + unit_name, qemu_config, + network_setup_scripts, network_teardown_scripts) self.service_manager.start_service(unit_name) logging.info(f'Started VM "{vm_id}"') @@ -253,7 +294,8 @@ class ComputingHandler: } def _create_qemu_systemd_unit( - self, unit_name: str, qemu_config: runtime.QemuStartupConfig): + self, unit_name: str, qemu_config: runtime.QemuStartupConfig, + setup_scripts: List[Path], teardown_scripts: List[Path]): qemu_name = \ f'qemu-vm-{qemu_config.vm_id},process=vm-{qemu_config.vm_id}' qemu_monitor_path = qemu_socket_monitor(qemu_config.vm_id) @@ -299,7 +341,11 @@ class ComputingHandler: f.write(f'Description=aetherscale VM {qemu_config.vm_id}\n') f.write('\n') f.write('[Service]\n') + for script in setup_scripts: + f.write(f'ExecStartPre={script.absolute()}\n') f.write(f'ExecStart={command}\n') + for script in teardown_scripts: + f.write(f'ExecStopPost={script.absolute()}\n') f.write('\n') f.write('[Install]\n') f.write('WantedBy=default.target\n') @@ -320,21 +366,22 @@ class ComputingHandler: vpn.create_config(config.HOSTNAME) vpn.gen_keypair() - # TODO: Must be re-established after host reboot # Create an uninitialized tap device so that tincd can run # without root permissions - # TODO: Assign an more reasonable IP address + # TODO: Assign a more reasonable IP address # TODO: In real environments the host does not have to be exposed, # this is only because I want to proxy IP traffic from the host to # the guest host_vpn_ip = vpn_network_prefix.replace('/64', '1') - networking.Iproute2Network.tap_device( - vpn.interface_name, aetherscale.config.USER) - networking.Iproute2Network.bridged_network( + iproute = networking.Iproute2Network() + iproute.tap_device(vpn.interface_name, aetherscale.config.USER) + iproute.bridged_network( vpn.bridge_interface_name, vpn.interface_name, ip=host_vpn_ip, flush_ip_device=False) + setup_network_script = iproute.setup_script() + teardown_network_script = iproute.teardown_script() - vpn.start_daemon() + vpn.start_daemon(setup_network_script, teardown_network_script) self.established_vpns[vpn_name] = vpn @@ -347,12 +394,9 @@ class ComputingHandler: f'with IPv6 address range {vpn_network_prefix}') # Create a new tap device for the VM to use - # TODO: Must be re-established after a host reboot associated_tap_device = 'vpn-' + vm_id - success = networking.Iproute2Network.tap_device( - associated_tap_device, config.USER, vpn.bridge_interface_name) - if not success: - raise VpnException(f'Could not setup tap for VPN "{vpn_name}"') + setup_tap_device(associated_tap_device, vpn.bridge_interface_name) + logging.debug( f'Created TAP device {associated_tap_device} for VM {vm_id}') @@ -470,11 +514,18 @@ def run(): queue=COMPETING_QUEUE, on_message_callback=bound_callback) # a TAP interface for VDE must already have been created + vde_tap_iproute = networking.Iproute2Network() + if not networking.Iproute2Network.check_device_existence(VDE_TAP_INTERFACE): - logging.error( - f'Interface {VDE_TAP_INTERFACE} does not exist. ' - 'Please create it manually and then start this service again') - sys.exit(1) + vde_tap_iproute.bridged_network( + 'br0', config.NETWORK_PHYSICAL_DEVICE, + config.NETWORK_IP, config.NETWORK_GATEWAY, + flush_ip_device=True) + vde_tap_iproute.tap_device(VDE_TAP_INTERFACE, config.USER, 'br0') + + if not vde_tap_iproute.setup(): + print('Could not setup VDE tap device', file=sys.stderr) + sys.exit(1) logging.info('Bringing up VDE networking') service_manager.install_service( @@ -491,3 +542,4 @@ def run(): channel.start_consuming() except KeyboardInterrupt: print('Keyboard interrupt, stopping service') + vde_tap_iproute.teardown() diff --git a/aetherscale/config.py b/aetherscale/config.py @@ -15,6 +15,12 @@ BASE_IMAGE_FOLDER = Path(os.getenv('BASE_IMAGE_FOLDER', default='base_images')) USER_IMAGE_FOLDER = Path(os.getenv('USER_IMAGE_FOLDER', default='user_images')) AETHERSCALE_CONFIG_DIR = Path.home() / '.config/aetherscale' +(AETHERSCALE_CONFIG_DIR / 'networking').mkdir(parents=True, exist_ok=True) + +NETWORK_IP = os.getenv('NETWORK_IP', default='192.168.0.1/24') +NETWORK_GATEWAY = os.getenv('NETWORK_GATEWAY', default='192.168.0.1') +NETWORK_PHYSICAL_DEVICE = os.getenv('NETWORK_PHYSICAL_DEVICE', default='eth0') + VPN_CONFIG_FOLDER = AETHERSCALE_CONFIG_DIR / 'tinc' VPN_NUM_PREPARED_INTERFACES = 2 VPN_48_PREFIX = 'fde7:2361:234a' diff --git a/aetherscale/execution.py b/aetherscale/execution.py @@ -1,9 +1,9 @@ import logging import subprocess -from typing import List +from typing import List, Iterator -def run_command_chain(commands: List[List[str]]) -> bool: +def run_command_chain(commands: Iterator[List[str]]) -> bool: for command in commands: logging.debug(f'Running command: {" ".join(command)}') result = subprocess.run(command) diff --git a/aetherscale/networking.py b/aetherscale/networking.py @@ -1,6 +1,7 @@ import logging import random import re +import shlex import subprocess from typing import Optional @@ -23,11 +24,14 @@ class NetworkingException(Exception): class Iproute2Network: - @staticmethod + def __init__(self): + self.creation_commands = [] + self.deletion_commands = [] + def bridged_network( - bridge_device: str, phys_device: str, + self, bridge_device: str, phys_device: str, ip: Optional[str] = None, gateway: Optional[str] = None, - flush_ip_device: bool = True) -> bool: + flush_ip_device: bool = True): Iproute2Network.validate_device_name(bridge_device) Iproute2Network.validate_device_name(phys_device) if ip: @@ -35,9 +39,9 @@ class Iproute2Network: if gateway: Iproute2Network.validate_ip_address(gateway) - Iproute2Network._create_bridge(bridge_device) + self._create_bridge(bridge_device) - commands = [ + self.creation_commands += [ ['sudo', 'ip', 'link', 'set', phys_device, 'up'], ['sudo', 'ip', 'link', 'set', phys_device, 'master', bridge_device], ['sudo', 'ip', 'addr', 'flush', 'dev', phys_device], @@ -45,20 +49,31 @@ class Iproute2Network: if ip: if flush_ip_device: - commands.append( + self.creation_commands.append( ['sudo', 'ip', 'addr', 'flush', 'dev', bridge_device]) - commands.append( + + self.creation_commands.append( ['sudo', 'ip', 'addr', 'add', ip, 'dev', bridge_device]) + if gateway: - commands.append( + self.creation_commands.append( ['sudo', 'ip', 'route', 'add', 'default', 'via', gateway, 'dev', bridge_device]) + self.deletion_commands.append( + ['sudo', 'ip', 'route', 'add', 'default', + 'via', gateway, 'dev', phys_device]) + self.deletion_commands.append( + ['sudo', 'ip', 'route', 'del', 'default']) - return execution.run_command_chain(commands) + if ip: + self.deletion_commands.append( + ['sudo', 'ip', 'addr', 'add', ip, 'dev', phys_device]) + + self.deletion_commands.append([ + 'sudo', 'ip', 'link', 'set', phys_device, 'nomaster']) - @staticmethod def tap_device( - tap_device_name: str, user: str, + self, tap_device_name: str, user: str, bridge_device: Optional[str] = None): Iproute2Network.validate_device_name(tap_device_name) if bridge_device: @@ -67,40 +82,61 @@ class Iproute2Network: if Iproute2Network.check_device_existence(tap_device_name): logging.debug( f'Device {tap_device_name} already exists, will not re-create') - return True else: logging.debug(f'Creating TAP device {tap_device_name}') - commands = [ + self.creation_commands += [ ['sudo', 'ip', 'tuntap', 'add', 'dev', tap_device_name, 'mode', 'tap', 'user', user], ['sudo', 'ip', 'link', 'set', 'dev', tap_device_name, 'up'], ] + self.deletion_commands.append( + ['sudo', 'ip', 'link', 'del', tap_device_name]) if bridge_device: - commands.append([ + self.creation_commands.append([ 'sudo', 'ip', 'link', 'set', tap_device_name, 'master', bridge_device, ]) + self.deletion_commands.append([ + 'sudo', 'ip', 'link', 'set', tap_device_name, 'nomaster']) - creation_ok = execution.run_command_chain(commands) - return creation_ok + def setup_script(self): + return Iproute2Network._to_script(self.creation_commands) + + def teardown_script(self): + return Iproute2Network._to_script(reversed(self.deletion_commands)) + + def setup(self): + return execution.run_command_chain(self.creation_commands) + + def teardown(self): + return execution.run_command_chain(reversed(self.deletion_commands)) @staticmethod - def _create_bridge(bridge_device: str): + def _to_script(commands): + script_lines = ['#!/usr/bin/env bash'] + + for command in commands: + script_lines.append(shlex.join(command)) + + return '\n'.join(script_lines) + + def _create_bridge(self, bridge_device: str): Iproute2Network.validate_device_name(bridge_device) if Iproute2Network.check_device_existence(bridge_device): logging.debug( f'Device {bridge_device} already exists, will not re-create') - return True else: logging.debug(f'Creating bridge device {bridge_device}') - return execution.run_command_chain([ + self.creation_commands += [ ['sudo', 'ip', 'link', 'add', bridge_device, 'type', 'bridge'], ['sudo', 'ip', 'link', 'set', bridge_device, 'up'], - ]) + ] + self.deletion_commands.append( + ['sudo', 'ip', 'link', 'del', bridge_device]) @staticmethod def check_device_existence(device: str) -> bool: diff --git a/aetherscale/vpn/tinc.py b/aetherscale/vpn/tinc.py @@ -8,6 +8,7 @@ import subprocess import tempfile from typing import Optional +from aetherscale import config from aetherscale.services import ServiceManager @@ -101,19 +102,33 @@ class TincVirtualNetwork(object): def _service_name(self) -> str: return f'aetherscale-tincd-{self.netname}.service' - def start_daemon(self): + def start_daemon( + self, setup_network_script: str, teardown_network_script: str): net_dir_quoted = shlex.quote(str(self._net_config_folder())) pidfile_quoted = shlex.quote(str(self.pidfile)) + network_conf_dir = config.AETHERSCALE_CONFIG_DIR / 'networking' + network_conf_dir.mkdir(parents=True, exist_ok=True) + setup_file = network_conf_dir / f'network-{self.netname}-setup.sh' + teardown_file = network_conf_dir / f'network-{self.netname}-teardown.sh' + with open(setup_file, 'w') as f: + f.write(setup_network_script) + os.chmod(setup_file, 0o755) + with open(teardown_file, 'w') as f: + f.write(teardown_network_script) + os.chmod(teardown_file, 0o755) + service_name = self._service_name() with tempfile.NamedTemporaryFile('wt') as f: f.write('[Unit]\n') f.write(f'Description=aetherscale {self.netname} VPN with tincd\n') f.write('\n') f.write('[Service]\n') + f.write(f'ExecStartPre={setup_file.absolute()}\n') f.write( f'ExecStart=tincd -D -c {net_dir_quoted} ' f'--pidfile {pidfile_quoted}\n') + f.write(f'ExecStopPost={teardown_file.absolute()}\n') f.write('\n') f.write('[Install]\n') f.write('WantedBy=default.target\n') diff --git a/bin/setup-tap-vde.sh b/bin/setup-tap-vde.sh @@ -1,49 +0,0 @@ -#!/usr/bin/env bash - -usage() { - echo "Usage: $0 -u USER -i IP_ADDRESS -g GATEWAY -e PHYSICAL_DEVICE" -} - -while getopts ":hu:i:g:e:" opt; do - case "$opt" in - h|\?) - usage - exit 0 - ;; - u) user=$OPTARG - ;; - i) ip_address=$OPTARG - ;; - g) gateway=$OPTARG - ;; - e) eth_device=$OPTARG - ;; - esac -done - -if [[ -z $user || -z $ip_address || -z $gateway || -z $eth_device ]]; then - usage - echo - echo "Please specify all required arguments" - exit 1 -fi - - -VDE_TAP=tap-vde - -ip link add br0 type bridge -ip link set br0 up - -ip link set $eth_device up -ip link set $eth_device master br0 - -# Drop existing IP from eth0 -ip addr flush dev $eth_device - -# Assign IP to br0 -ip addr add $ip_address brd + dev br0 -ip route add default via $gateway dev br0 - -ip tuntap add dev $VDE_TAP mode tap user $user -ip link set dev $VDE_TAP up -ip link set $VDE_TAP master br0 diff --git a/tests/test_networking.py b/tests/test_networking.py @@ -1,3 +1,4 @@ +from unittest import mock import pytest from aetherscale import networking @@ -35,3 +36,31 @@ def test_ip_address_validation(): with pytest.raises(networking.NetworkingException): networking.Iproute2Network.validate_ip_address('something-invalid') + + +def test_iproute2_networking_scripts(): + iproute = networking.Iproute2Network() + iproute.bridged_network('br0', 'eth0', '10.0.0.2/24', '10.0.0.1') + iproute.tap_device('tap0', 'myuser', 'br0') + setup_script = iproute.setup_script() + teardown_script = iproute.teardown_script() + + assert 'link add br0 type bridge' in setup_script + assert 'set eth0 master br0' in setup_script + assert 'addr add 10.0.0.2/24 dev br0' in setup_script + assert 'tuntap add dev tap0' in setup_script + + assert 'link del br0' in teardown_script + assert 'link del tap0' in teardown_script + assert 'addr add 10.0.0.2/24 dev eth0' in teardown_script + + +@mock.patch('aetherscale.execution.run_command_chain') +def test_iproute2_networking_direct_execution(command_chain): + iproute = networking.Iproute2Network() + iproute.bridged_network('br0', 'eth0') + + iproute.setup() + + bridge_command = ['sudo', 'ip', 'link', 'add', 'br0', 'type', 'bridge'] + assert bridge_command in command_chain.call_args[0][0]