This commit is contained in:
mwiegand 2021-06-13 21:47:35 +02:00
parent c51d226f89
commit 49d2572998
12 changed files with 550 additions and 4 deletions

View file

@ -0,0 +1,3 @@
% for nameserver in sorted(node.metadata.get('nameservers', {'9.9.9.10', '2620:fe::10'})):
nameserver ${nameserver}
% endfor

View file

@ -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

View file

@ -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': {},
}

View file

@ -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,
}

View file

@ -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

View file

@ -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',
},
},
}

View file

@ -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,
},
},
}

View file

@ -5,6 +5,7 @@
'bundles': [
'apt',
'systemd',
'systemd-networkd',
],
'os': 'debian',
'pip_command': 'pip3',

15
libs/keys.py Normal file
View file

@ -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)

88
libs/tools.py Normal file
View file

@ -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}')

View file

@ -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': {},
},
},
},
}

View file

@ -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': {},
},
},
},
}