diff --git a/bundles/systemd-networkd/files/resolv.conf b/bundles/systemd-networkd/files/resolv.conf new file mode 100644 index 0000000..8f9ee33 --- /dev/null +++ b/bundles/systemd-networkd/files/resolv.conf @@ -0,0 +1,3 @@ +% for nameserver in sorted(node.metadata.get('nameservers', {'9.9.9.10', '2620:fe::10'})): +nameserver ${nameserver} +% endfor diff --git a/bundles/systemd-networkd/files/template-iface-nodhcp.network b/bundles/systemd-networkd/files/template-iface-nodhcp.network new file mode 100644 index 0000000..bdbaed0 --- /dev/null +++ b/bundles/systemd-networkd/files/template-iface-nodhcp.network @@ -0,0 +1,50 @@ +<% + from ipaddress import ip_network +%>\ +[Match] +Name=${interface} + +% for addr in sorted(config.get('ips', set())): +[Address] +<% + if '/' in addr: + ip, prefix = addr.split('/') + else: + ip = addr + prefix = '32' +%>\ +Address=${ip}/${prefix} + +% endfor +% for route, rconfig in sorted(config.get('routes', {}).items()): +[Route] +% if 'via' in rconfig: +Gateway=${rconfig['via']} +% endif +Destination=${route} +GatewayOnlink=yes + +% endfor +% if 'gateway4' in config: +[Route] +Gateway=${config['gateway4']} +GatewayOnlink=yes + +% endif +% if 'gateway6' in config: +[Route] +Gateway=${config['gateway6']} +GatewayOnlink=yes + +% endif +[Network] +DHCP=no +IPv6AcceptRA=no + +% if config.get('forwarding', False): +IPForward=yes +%endif + +% for vlan in sorted(config.get('vlans', set())): +VLAN=${interface}.${vlan} +% endfor diff --git a/bundles/systemd-networkd/items.py b/bundles/systemd-networkd/items.py new file mode 100644 index 0000000..ca78e49 --- /dev/null +++ b/bundles/systemd-networkd/items.py @@ -0,0 +1,142 @@ +assert node.has_bundle('systemd') + +from bundlewrap.exceptions import BundleError + + +files = { + '/etc/network/interfaces': { + 'delete': True, + }, +} + +if node.metadata.get('systemd-networkd/enable-resolved', False): + symlinks['/etc/resolv.conf'] = { + 'target': '/run/systemd/resolve/stub-resolv.conf', + } + svc_systemd['systemd-resolved'] = {} +else: + files['/etc/resolv.conf'] = { + 'content_type': 'mako', + } + + +directories = { + '/etc/systemd/network': { + 'purge': True, + }, +} + +mac_host_prefix = '%04x' % (node.magic_number % 65534) +generated_mac = f'52:54:00:{mac_host_prefix[0:2]}:{mac_host_prefix[2:4]}:{{}}' + +# Don't use .get() here. We might end up with a node without a network +# config! +for interface, config in node.metadata['interfaces'].items(): + if config.get('dhcp', False): + if 'vlans' in config: + raise BundleError(f'{node.name} interface {interface} cannot use vlans and dhcp!') + template = 'template-iface-dhcp.network' + else: + template = 'template-iface-nodhcp.network' + + if '.' in interface: + vlan_id = int(interface.split('.')[1]) + vlan_hex = '%02x' % (vlan_id % 255) + files['/etc/systemd/network/60-iface-{}.netdev'.format(interface)] = { + 'source': 'template-iface-vlan.netdev', + 'content_type': 'mako', + 'context': { + 'interface': interface, + 'vlan': vlan_id, + 'mac': generated_mac.format(vlan_hex) + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + weight = 61 + else: + weight = 50 + + if not config.get('ignore', False): + files['/etc/systemd/network/{}-iface-{}.network'.format(weight, interface)] = { + 'source': template, + 'content_type': 'mako', + 'context': { + 'interface': interface, + 'config': config, + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + +for bond, config in node.metadata.get('systemd-networkd/bonds', {}).items(): + files['/etc/systemd/network/20-bond-{}.netdev'.format(bond)] = { + 'source': 'template-bond.netdev', + 'content_type': 'mako', + 'context': { + 'bond': bond, + 'mode': config.get('mode', '802.3ad'), + 'prio': config.get('priority', '32768'), + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + files['/etc/systemd/network/21-bond-{}.network'.format(bond)] = { + 'source': 'template-bond.network', + 'content_type': 'mako', + 'context': { + 'bond': bond, + 'match': config['match'], + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + +for brname, config in node.metadata.get('systemd-networkd/bridges', {}).items(): + files['/etc/systemd/network/30-bridge-{}.netdev'.format(brname)] = { + 'source': 'template-bridge.netdev', + 'content_type': 'mako', + 'context': { + 'bridge': brname, + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + files['/etc/systemd/network/31-bridge-{}.network'.format(brname)] = { + 'source': 'template-bridge.network', + 'content_type': 'mako', + 'context': { + 'bridge': brname, + 'match': config['match'], + }, + 'needed_by': { + 'svc_systemd:systemd-networkd', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + } + +svc_systemd = { + 'systemd-networkd': {}, +} diff --git a/bundles/systemd-networkd/metadata.py b/bundles/systemd-networkd/metadata.py new file mode 100644 index 0000000..e8dff0e --- /dev/null +++ b/bundles/systemd-networkd/metadata.py @@ -0,0 +1,29 @@ +defaults = { + 'apt': { + 'packages': { + 'resolvconf': { + 'installed': False, + }, + }, + }, +} + + +@metadata_reactor.provides( + 'interfaces', +) +def add_vlan_infos_to_interface(metadata): + interfaces = {} + + for iface in metadata.get('interfaces', {}): + if not '.' in iface: + continue + + interface,vlan = iface.split('.') + + interfaces.setdefault(interface, {}).setdefault('vlans', set()) + interfaces[interface]['vlans'].add(vlan) + + return { + 'interfaces': interfaces, + } diff --git a/bundles/wireguard/files/wg0.netdev b/bundles/wireguard/files/wg0.netdev new file mode 100644 index 0000000..8bc13a0 --- /dev/null +++ b/bundles/wireguard/files/wg0.netdev @@ -0,0 +1,25 @@ +[NetDev] +Name=wg0 +Kind=wireguard +Description=WireGuard server + +[WireGuard] +PrivateKey=${privatekey} +ListenPort=51820 + +% for peer, config in sorted(peers.items()): +# Peer ${peer} +[WireGuardPeer] +PublicKey=${config['pubkey']} +% if len(peers) == 1: # FIXME +AllowedIPs=${network} +% else: +AllowedIPs=${','.join(sorted(config['ips']))} +% endif +PresharedKey=${config['psk']} +% if 'endpoint' in config: +Endpoint=${config['endpoint']} +% endif +PersistentKeepalive=30 + +% endfor diff --git a/bundles/wireguard/items.py b/bundles/wireguard/items.py new file mode 100644 index 0000000..d24ff09 --- /dev/null +++ b/bundles/wireguard/items.py @@ -0,0 +1,21 @@ +from ipaddress import ip_network + +repo.libs.tools.require_bundle(node, 'systemd-networkd') + +network = ip_network(node.metadata['wireguard']['my_ip'], strict=False) + +files = { + '/etc/systemd/network/wg0.netdev': { + 'content_type': 'mako', + 'context': { + 'network': f'{network.network_address}/{network.prefixlen}', + **node.metadata['wireguard'], + }, + 'needs': { + 'pkg_apt:wireguard', + }, + 'triggers': { + 'svc_systemd:systemd-networkd:restart', + }, + }, +} diff --git a/bundles/wireguard/metadata.py b/bundles/wireguard/metadata.py new file mode 100644 index 0000000..4f9de05 --- /dev/null +++ b/bundles/wireguard/metadata.py @@ -0,0 +1,140 @@ +from ipaddress import ip_network + +from bundlewrap.exceptions import NoSuchNode +from bundlewrap.metadata import atomic + + +defaults = { + 'apt': { + 'packages': { + 'wireguard': {}, + }, + }, + 'wireguard': { + 'privatekey': repo.libs.keys.gen_privkey(repo, f'{node.name} wireguard privatekey'), + }, +} + + +@metadata_reactor.provides( + 'wireguard/peers', +) +def peer_psks(metadata): + peers = {} + + for peer_name in metadata.get('wireguard/peers', {}): + peers[peer_name] = {} + + if node.name < peer_name: + peers[peer_name] = { + 'psk': repo.vault.random_bytes_as_base64_for(f'{node.name} wireguard {peer_name}'), + } + else: + peers[peer_name] = { + 'psk': repo.vault.random_bytes_as_base64_for(f'{peer_name} wireguard {node.name}'), + } + + return { + 'wireguard': { + 'peers': peers, + }, + } + + +@metadata_reactor.provides( + 'wireguard/peers', +) +def peer_pubkeys(metadata): + peers = {} + + for peer_name in metadata.get('wireguard/peers', {}): + try: + rnode = repo.get_node(peer_name) + except NoSuchNode: + continue + + peers[peer_name] = { + 'pubkey': repo.libs.keys.get_pubkey_from_privkey( + repo, + f'{rnode.name} wireguard pubkey', + rnode.metadata.get('wireguard/privatekey'), + ), + } + + return { + 'wireguard': { + 'peers': peers, + }, + } + + +@metadata_reactor.provides( + 'wireguard/peers', +) +def peer_ips_and_endpoints(metadata): + peers = {} + + for peer_name in metadata.get('wireguard/peers', {}): + try: + rnode = repo.get_node(peer_name) + except NoSuchNode: + continue + + ips = rnode.metadata.get('wireguard/subnets', set()) + ips.add(rnode.metadata.get('wireguard/my_ip').split('/')[0]) + ips = repo.libs.tools.remove_more_specific_subnets(ips) + + peers[rnode.name] = { + 'endpoint': '{}:51820'.format(rnode.metadata.get('wireguard/external_hostname', rnode.hostname)), + 'ips': ips, + } + + return { + 'wireguard': { + 'peers': peers, + }, + } + + +@metadata_reactor.provides( + 'interfaces/wg0/ips', +) +def interface_ips(metadata): + return { + 'interfaces': { + 'wg0': { + 'ips': { + metadata.get('wireguard/my_ip'), + }, + }, + }, + } + + +@metadata_reactor.provides( + 'interfaces/wg0/routes', +) +def routes(metadata): + network = ip_network(metadata.get('wireguard/my_ip'), strict=False) + ips = { + f'{network.network_address}/{network.prefixlen}', + } + routes = {} + + for _, peer_config in metadata.get('wireguard/peers', {}).items(): + for ip in peer_config['ips']: + ips.add(ip) + + if '0.0.0.0/0' in ips: + ips.remove('0.0.0.0/0') + + for ip in repo.libs.tools.remove_more_specific_subnets(ips): + routes[ip] = {} + + return { + 'interfaces': { + 'wg0': { + 'routes': routes, + }, + }, + } diff --git a/groups/os/debian.py b/groups/os/debian.py index 6f908af..c8d8142 100644 --- a/groups/os/debian.py +++ b/groups/os/debian.py @@ -5,6 +5,7 @@ 'bundles': [ 'apt', 'systemd', + 'systemd-networkd', ], 'os': 'debian', 'pip_command': 'pip3', diff --git a/libs/keys.py b/libs/keys.py new file mode 100644 index 0000000..1565fee --- /dev/null +++ b/libs/keys.py @@ -0,0 +1,15 @@ +import base64 +from nacl.public import PrivateKey +from nacl.encoding import Base64Encoder +from bundlewrap.utils import Fault + +def gen_privkey(repo, identifier): + return repo.vault.random_bytes_as_base64_for(identifier) + +def get_pubkey_from_privkey(repo, identifier, privkey): + # FIXME this assumes the privkey is always a base64 encoded string + def derive_pubkey(): + pub_key = PrivateKey(base64.b64decode(str(privkey))).public_key + return pub_key.encode(encoder=Base64Encoder).decode('ascii') + + return Fault(f'pubkey from privkey {identifier}', derive_pubkey) diff --git a/libs/tools.py b/libs/tools.py new file mode 100644 index 0000000..d96feec --- /dev/null +++ b/libs/tools.py @@ -0,0 +1,88 @@ +from ipaddress import ip_address, ip_network, IPv4Address, IPv4Network + +from bundlewrap.exceptions import NoSuchGroup, NoSuchNode, BundleError +from bundlewrap.utils.text import bold, red +from bundlewrap.utils.ui import io + +def resolve_identifier(repo, identifier): + """ + Try to resolve an identifier (group or node). Return a set of ip + addresses valid for this identifier. + """ + try: + nodes = {repo.get_node(identifier)} + except NoSuchNode: + try: + nodes = repo.nodes_in_group(identifier) + except NoSuchGroup: + try: + ip = ip_network(identifier) + + if isinstance(ip, IPv4Network): + return {'ipv4': {ip}, 'ipv6': set()} + else: + return {'ipv4': set(), 'ipv6': {ip}} + except Exception as e: + io.stderr('{x} {t} Exception while resolving "{i}": {e}'.format( + x=red('✘'), + t=bold('libs.tools.resolve_identifier'), + i=identifier, + e=str(e), + )) + raise + + found_ips = set() + for node in nodes: + for interface, config in node.metadata.get('interfaces', {}).items(): + for ip in config.get('ips', set()): + if '/' in ip: + found_ips.add(ip_address(ip.split('/')[0])) + else: + found_ips.add(ip_address(ip)) + + if node.metadata.get('external_ipv4', None): + found_ips.add(ip_address(node.metadata.get('external_ipv4'))) + + ip_dict = { + 'ipv4': set(), + 'ipv6': set(), + } + + for ip in found_ips: + if isinstance(ip, IPv4Address): + ip_dict['ipv4'].add(ip) + else: + ip_dict['ipv6'].add(ip) + + return ip_dict + + +def remove_more_specific_subnets(input_subnets) -> list: + final_subnets = [] + + for subnet in sorted(input_subnets): + source = ip_network(subnet) + + if not source in final_subnets: + subnet_found = False + + for dest_subnet in final_subnets: + if source.subnet_of(dest_subnet): + subnet_found = True + + if not subnet_found: + final_subnets.append(source) + + out = [] + for net in final_subnets: + out.append(str(net)) + + return out + + +def require_bundle(node, bundle, hint=''): + # It's considered bad style to use assert statements outside of tests. + # That's why this little helper function exists, so we have an easy + # way of defining bundle requirements in other bundles. + if not node.has_bundle(bundle): + raise BundleError(f'{node.name} requires bundle {bundle}, but wasn\'t found! {hint}') diff --git a/nodes/home.server.py b/nodes/home.server.py index 7c50a90..007cdea 100644 --- a/nodes/home.server.py +++ b/nodes/home.server.py @@ -1,17 +1,32 @@ { 'hostname': '10.0.0.2', - 'bundles': [ - 'gitea', - 'postgresql', - ], 'groups': [ 'debian-10', ], + 'bundles': [ + 'gitea', + 'postgresql', + 'wireguard', + ], 'metadata': { + 'interfaces': { + 'enp1s0f0': { + 'ips': { + '10.0.0.2/24', + }, + 'gateway4': '10.0.0.1', + }, + }, 'gitea': { 'version': '1.14.2', 'sha256': '0d11d87ce60d5d98e22fc52f2c8c6ba2b54b14f9c26c767a46bf102c381ad128', 'domain': 'git.sublimity.de', }, + 'wireguard': { + 'my_ip': '172.19.136.1/22', + 'peers': { + 'htz.mails': {}, + }, + }, }, } diff --git a/nodes/htz.mails.py b/nodes/htz.mails.py index 95f98e4..768b71b 100644 --- a/nodes/htz.mails.py +++ b/nodes/htz.mails.py @@ -6,9 +6,20 @@ 'webserver', ], 'bundles': [ + 'wireguard', 'zfs', ], 'metadata': { + 'interfaces': { + 'eth0': { + 'ips': { + '162.55.188.157', + '2a01:4f8:1c1c:4121::/64', + }, + 'gateway4': '172.31.1.1', + 'gateway6': 'fe80::1', + }, + }, 'nginx': { 'vhosts': { 'nextcloud': { @@ -38,5 +49,11 @@ }, }, }, + 'wireguard': { + 'my_ip': '172.19.136.2/22', + 'peers': { + 'home.server': {}, + }, + }, }, }