From 99148c81119d8bafa4a27a41ba0dc1cc48f9e5b0 Mon Sep 17 00:00:00 2001 From: Nico Boehr <nico@nicoboehr.de> Date: Thu, 26 Dec 2024 20:29:21 +0100 Subject: [PATCH] add intermediate version of BGP backbone This adds (incomplete) support for configuring the BGP backbone that could until now only be deployed locally using shell scripts. The idea is to share the CSV-based config files between the shell scripts and the new Ansible-based approach to allow peaceful coexistence of both deployment variants. Note that the Ansible-based approach verifies input data much stricter than the shell scripts to prevent common user errors, so not everything will work. For Ansible to find the config CSVs, a symlink "backbone-conf" to the conf directory of the backbone repo[1] is expected in the ansible root directory. Currently, the ansible-based approach can only deploy Wireguard tunnels, but will be extended to also configure Bird2 sessions. [1] https://github.com/freifunk-stuttgart/backbone --- .gitignore | 2 + backbone.yml | 4 + inventory/backbone | 3 + roles/backbone/tasks/main.yml | 14 ++ roles/backbone/tasks/wireguard_line.yml | 6 + roles/backbone/tasks/wireguard_tunnel.yml | 46 +++++ roles/backbone/templates/wg-tunnel.netdev.j2 | 15 ++ .../vars_plugins/backbone_csv_vars.py | 158 ++++++++++++++++++ 8 files changed, 248 insertions(+) create mode 100644 backbone.yml create mode 100644 roles/backbone/tasks/main.yml create mode 100644 roles/backbone/tasks/wireguard_line.yml create mode 100644 roles/backbone/tasks/wireguard_tunnel.yml create mode 100644 roles/backbone/templates/wg-tunnel.netdev.j2 create mode 100644 roles/backbone/vars_plugins/backbone_csv_vars.py diff --git a/.gitignore b/.gitignore index 8393163..35b9f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .history ansible.out **.swp +**/__pycache__/ +**.pyc diff --git a/backbone.yml b/backbone.yml new file mode 100644 index 0000000..36b80a5 --- /dev/null +++ b/backbone.yml @@ -0,0 +1,4 @@ +--- +- hosts: backbone + roles: + - backbone diff --git a/inventory/backbone b/inventory/backbone index 4a70346..f18c088 100644 --- a/inventory/backbone +++ b/inventory/backbone @@ -3,4 +3,7 @@ backbone: hosts: nrb-backbonetest.freifunk-stuttgart.de: ansible_ssh_host: ffs-backbonetest + nrb-backbonetest2.freifunk-stuttgart.de: + ansible_ssh_host: 192.168.122.3 + ansible_ssh_user: root gw09n03.gw.freifunk-stuttgart.de: diff --git a/roles/backbone/tasks/main.yml b/roles/backbone/tasks/main.yml new file mode 100644 index 0000000..2ffb0ba --- /dev/null +++ b/roles/backbone/tasks/main.yml @@ -0,0 +1,14 @@ +--- +- name: Install required packages + apt: + name: + - bird2 + - wireguard-tools + state: present + +- name: Deploy lines + include_tasks: wireguard_line.yml + loop: "{{ bb_bgp_peers[ansible_facts['hostname']].lines }}" + loop_control: + loop_var: local_line + diff --git a/roles/backbone/tasks/wireguard_line.yml b/roles/backbone/tasks/wireguard_line.yml new file mode 100644 index 0000000..06de9b1 --- /dev/null +++ b/roles/backbone/tasks/wireguard_line.yml @@ -0,0 +1,6 @@ +--- +- name: Deploy wireguard tunnel + include_tasks: wireguard_tunnel.yml + loop: "{{ local_line.tunnels }}" + loop_control: + loop_var: tunnel diff --git a/roles/backbone/tasks/wireguard_tunnel.yml b/roles/backbone/tasks/wireguard_tunnel.yml new file mode 100644 index 0000000..51cda9d --- /dev/null +++ b/roles/backbone/tasks/wireguard_tunnel.yml @@ -0,0 +1,46 @@ +--- +- ansible.builtin.set_fact: + bb_bgp_remote_line_name: "{{ tunnel.line_b if bb_bgp_lines[tunnel.line_a].hostname == ansible_facts['hostname'] else tunnel.line_a }}" + bb_bgp_local_line_name: "{{ tunnel.line_a if bb_bgp_lines[tunnel.line_a].hostname == ansible_facts['hostname'] else tunnel.line_b }}" + +# this is seperate from the above because it depends on a fact set above +- ansible.builtin.set_fact: + bb_bgp_remote_line: "{{ bb_bgp_lines[bb_bgp_remote_line_name] }}" + bb_bgp_local_line: "{{ bb_bgp_lines[bb_bgp_local_line_name] }}" + +# this is seperate from the above because it depends on a fact set above +- ansible.builtin.set_fact: + bb_bgp_remote_host: "{{ bb_bgp_peers[bb_bgp_remote_line.hostname] }}" + bb_bgp_local_host: "{{ bb_bgp_peers[bb_bgp_local_line.hostname] }}" + +- ansible.builtin.debug: + msg: "{{ bb_bgp_local_line }}" +- ansible.builtin.debug: + msg: "{{ bb_bgp_remote_line }}" + +- name: Deploy netdev file for wireguard tunnel + ansible.builtin.template: + src: wg-tunnel.netdev.j2 + dest: "/etc/systemd/network/{{ bb_bgp_remote_line_name }}.netdev" + mode: 0750 + owner: root + group: systemd-network + vars: + local_port: "{{ 12000 + (tunnel.idx|int - 1) * 1000 + bb_bgp_remote_host.ip4offset|int }}" + remote_port: "{{ 12000 + (tunnel.idx|int - 1) * 1000 + bb_bgp_local_host.ip4offset|int }}" + +- name: Deploy network file for wireguard tunnel + ansible.builtin.template: + src: wg-tunnel.network.j2 + dest: "/etc/systemd/network/{{ bb_bgp_remote_line_name }}.network" + mode: 0750 + owner: root + group: systemd-network + +- ansible.builtin.set_fact: + bb_bgp_remote_line_name: "" + bb_bgp_local_line_name: "" + bb_bgp_remote_line: "" + bb_bgp_local_line: "" + bb_bgp_remote_host: "" + bb_bgp_local_host: "" diff --git a/roles/backbone/templates/wg-tunnel.netdev.j2 b/roles/backbone/templates/wg-tunnel.netdev.j2 new file mode 100644 index 0000000..edcccb9 --- /dev/null +++ b/roles/backbone/templates/wg-tunnel.netdev.j2 @@ -0,0 +1,15 @@ +[NetDev] +Name={{ bb_bgp_remote_line_name }} +Kind=wireguard +MTUBytes=1280 + +[WireGuard] +ListenPort={{ local_port }} +PrivateKeyFile=/etc/wireguard/wg-private.key + +[WireGuardPeer] +PublicKey={{ bb_bgp_remote_host.wg_pubkey }} +AllowedIPs=0.0.0.0/0, ::/0 +Endpoint={{ bb_bgp_remote_line.ip }}:{{ remote_port }} + + diff --git a/roles/backbone/vars_plugins/backbone_csv_vars.py b/roles/backbone/vars_plugins/backbone_csv_vars.py new file mode 100644 index 0000000..201b38b --- /dev/null +++ b/roles/backbone/vars_plugins/backbone_csv_vars.py @@ -0,0 +1,158 @@ +import os.path +import pathlib +import csv +from dataclasses import dataclass, asdict +from ansible.plugins.vars import BaseVarsPlugin + +@dataclass +class Tunnel: + """ + A wireguard tunnel connects two lines with each other. + """ + line_a: str + line_b: str + idx: int + +@dataclass +class Line: + """ + A line over which a wireguard tunnel can be established to a BgpPeer. + """ + hostname: str + linename: str + ip: str + tunnels: list[Tunnel] + +@dataclass +class BgpPeer: + """ + A router speaking BGP. Consists of multile @Line. + """ + hostname: str + asn: str + routerid: str + lines: list[Line] + wg_pubkey: str + ip4offset: int + ip6offset: int + +class VarsModule(BaseVarsPlugin): + def get_vars(self, loader, path, entities): + if not "all" in map(lambda e: e.name, entities): + return {} + + self._path_backbone_conf = pathlib.Path(path) / "backbone-conf" + + if self._path_backbone_conf.is_dir(): + return self.parse_backbone_conf() + return {} + + def _make_csv_reader(self, fp): + return csv.reader(fp, + delimiter=';', + skipinitialspace=True) + + def _csv_iterator(self, fp): + csv = self._make_csv_reader(fp) + while True: + try: + line = list(map(lambda s: s.strip(), next(csv))) + except StopIteration: + return + if line[0].startswith("#"): + continue + yield line + + def _parse_host_keyvalue(self, csvfilename, name): + output = {} + csv_path = self._path_backbone_conf / csvfilename + with csv_path.open() as input_fp: + for item in self._csv_iterator(input_fp): + if item[0] in output: + raise ValueError(f"Duplicate {name} for {item[0]}") + output[item[0]] = item[1] + + return output + + def parse_backbone_conf(self): + wg_pubkeys = self._parse_host_keyvalue("wireguard-pubkeys", "wireguard pubkey") + ip4offsets = self._parse_host_keyvalue("ipoffsets", "ip4offset") + ip6offsets = self._parse_host_keyvalue("ip6offsets", "ip6offset") + + bgp_peers = {} + bgp_lines_path = self._path_backbone_conf / "bgp_lines" + with bgp_lines_path.open() as bgp_lines_fp: + for bgp_line_csv in self._csv_iterator(bgp_lines_fp): + hostname = bgp_line_csv[0] + try: + wg_pubkey = wg_pubkeys[hostname] + except KeyError: + print(f"WARNING: Missing wireguard pubkey for {hostname}") + try: + ip4offset = ip4offsets[hostname] + except KeyError: + print(f"WARNING: Missing ip4offset for {hostname}") + try: + ip6offset = ip6offsets[hostname] + except KeyError: + print(f"WARNING: Missing ip6offset for {hostname}") + bgp_peer = BgpPeer(hostname=hostname, + asn=bgp_line_csv[2], + routerid=bgp_line_csv[3], + wg_pubkey=wg_pubkey, + ip4offset=ip4offset, + ip6offset=ip6offset, + lines=[]) + if bgp_peer.hostname in bgp_peers: + other_bgp_peer = bgp_peers[bgp_peer.hostname] + if other_bgp_peer.asn != bgp_peer.asn: + raise ValueError(f"Inconsistent ASN for {bgp_peer.hostname} in bgp_lines {other_bgp_peer.asn} != {bgp_peer.asn}") + if other_bgp_peer.routerid != bgp_peer.routerid: + raise ValueError(f"Inconsistent ASN for {bgp_peer.hostname} in bgp_lines {other_bgp_peer.routerid} != {bgp_peer.routerid}") + continue + bgp_peers[bgp_peer.hostname] = bgp_peer + + lines = {} + lines_path = self._path_backbone_conf / "lines" + with lines_path.open() as lines_fp: + for line_csv in self._csv_iterator(lines_fp): + line = Line(hostname=line_csv[0], + linename=line_csv[1], + ip=line_csv[3], + tunnels=[]) + if line.linename in lines: + raise ValueError(f"Linename {line.linename} already exists") + try: + bgp_peers[line.hostname].lines.append(line) + except KeyError: + raise ValueError(f"Could not find hostname {line.hostname} for line {line.linename} in bgp_peers") + lines[line.linename] = line + + tunnels_path = self._path_backbone_conf / "tunnels" + tunnels_next_idx = {} + with tunnels_path.open() as tunnels_fp: + for tunnel_csv in self._csv_iterator(tunnels_fp): + tunnel_csv = list(map(lambda s: s.strip(), tunnel_csv)) + if tunnel_csv[0].startswith("#"): + continue + line_a = tunnel_csv[0] if tunnel_csv[0] < tunnel_csv[1] else tunnel_csv[1] + line_b = tunnel_csv[1] if tunnel_csv[0] < tunnel_csv[1] else tunnel_csv[0] + hostname_a = lines[line_a].hostname + hostname_b = lines[line_b].hostname + tunnel_key = f"{hostname_a}-{hostname_b}" + try: + tunnels_next_idx[tunnel_key] += 1 + except KeyError: + tunnels_next_idx[tunnel_key] = 1 + + tunnel = Tunnel(line_a=line_a, + line_b=line_b, + idx=tunnels_next_idx[tunnel_key]) + try: + lines[line_a].tunnels.append(tunnel) + lines[line_b].tunnels.append(tunnel) + except KeyError: + raise ValueError(f"Line {linea} or {lineb} do not exist") + + return {"bb_bgp_peers": {peername: asdict(peer) for peername, peer in bgp_peers.items()}, + "bb_bgp_lines": {linename: asdict(line) for linename, line in lines.items()}} -- GitLab