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