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