%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% Descr: Vorlage für Berichte der DHBW-Karlsruhe, Ein Kapitel %% Author: Prof. Dr. Jürgen Vollmer, vollmer@dhbw-karlsruhe.de %% $Id: kapitel1.tex,v 1.24 2020/03/13 16:02:34 vollmer Exp $ %% -*- coding: utf-8 -*- %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% \chapter{API Komponente} \section{Aufgaben} Die \ac{API} ist die Schnittstelle des Service und außen stehenden Technologien wie der Anexia Engine. Ihre Hauptaufgabe besteht darin, eine strukturierte Interaktionsmöglichkeit zu bieten, die es internen Benutzern über Systeme wie der Anexia Engine ermöglicht, \ac{BGP}-Routen mit zugehörigen \ac{BGP}-Communities in das Netzwerk zu injizieren. Dies geschieht durch die Annahme von \ac{JSON}-Anfragen, die spezifische Informationen enthalten, nämlich IPv4- oder IPv6-Präfixe und die entsprechenden \ac{BGP}-Communities. Die \ac{API} führt eine umfassende Validierung der eingehenden Daten durch, um sicherzustellen, dass die bereitgestellten Informationen korrekt und im erwarteten Format vorliegen. Diese Validierung umfasst die Überprüfung der Richtigkeit der IP-Adressbereiche sowie die syntaktische Korrektheit der zugeordneten \ac{BGP}-Communities. Durch diesen Schritt wird gewährleistet, dass nur gültige Informationen in das System eingebracht werden. Die validierten Daten werden anschließend an Consul, über dessen eigene \ac{API} übermittelt. Die Daten werden so abgelegt, dass der Injector einen erleichterten Zugriff hat. \newpage \section{Umsetzung} Da die Konzeption und Implementierung der \ac{API} schon umfassend in der Projektarbeit T1000 erläutert wurde, wird auf eine Wiederholung dessen verzichtet. In diesem Bericht wird lediglich die Implementierung des `Delete`-Endpunkts dargestellt, da dieser aus zeitlichen Gründen nicht mehr in den ersten beiden Praxisphase implementiert werden konnte, jedoch ein Grundbestandteil des entwickelten Service ist. \vspace{1cm} Die Implementierung eines `Delete`-Endpunkts in der API, mittels des Django Rest Frameworks, ermöglicht das Löschen von Routen aus dem System. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:baserouteviewset}, captionpos=b, % Caption unter den Code setzen caption={BaseRouteViewset Klasse}] class BaseRouteViewSet( CreateModelMixin, ReadOnlyModelViewSet, BaseRequestViewSet, ): @action(detail=False, url_path=r"([A-Za-z-_/]*)status/(?P[0-9a-z-]+)") def status(self, request, task_info_id): route_object = get_object_or_404( self.serializer_class.Meta.model, task_info_id=task_info_id ) propagate_status(route_object) return super().status(request, task_info_id) \end{lstlisting} Der in Snippet 4.1 gezeigte Code stellt eine Mutterklasse dar, von welcher sowohl der `Create`, als auch `Delete`-Endpunkt erben. Durch diese Klasse wird die Möglichkeit gegeben, von der Anexia Engine erwartete Endpunkte einfach zu implementieren, ohne dass sich ein Entwickler mit den Feinheiten dessen auseinandersetzen muss. Da hier die \verb|CreateModelMixin| Klasse geerbt wird, stellt sich das \ac{DRF} automatisch ein 'POST'-Requests für diesen Endpunkt zu akzeptieren. \newpage \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:deleterouteviewset}, captionpos=b, % Caption unter den Code setzen caption={DeleteRouteViewset Klasse}] class DeleteRouteViewSet(BaseRouteViewSet): queryset = DeleteRoute.objects.all() serializer_class = DeleteRouteSerializer def perform_create(self, serializer): super().perform_create(serializer) delete_route(serializer.instance) \end{lstlisting} Die tatsächliche Implementierung fällt durch das Erben von der `BaseRouteViewSet` Mutterklasse sehr simpel aus. Durch das Überschreiben der \verb|perform_create| Methode, welche vom \ac{DRF} zur Verfügung gestellt wird, kann diese als Hook benutzt werden um eigenen Code ausführen zu lassen. Mit der Super Methode wird sichergestellt, dass die nicht überschriebene Ursprungsmethode von \verb|perform_create| ausgeführt wird. Das \ac{DRF} erstellt dann einen Datenbankeintrag mit den vom Nutzer eingegeben Werten. Vor dem Ende des Kontextes der Methode wird noch eine weitere Methode \verb|delete_route| aufgerufen. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:deleteroute}, captionpos=b, % Caption unter den Code setzen caption={delete\_route Methode}] def delete_route(instance): consul_instance = prepare_consul(os.getenv("CONSUL_HOST"), os.getenv("CONSUL_PORT")) prefix = str(instance.prefix) prefix_encoding = get_prefix_encoding(prefix) consul_instance.kv.delete( f'v1/route/global/{prefix_encoding}/{prefix.replace("/", "_")}' ) update_active_injectors(instance) \end{lstlisting} Hier findet nun das eigentliche Übermitteln der Daten an Consul statt. \chapter{Injector Komponente} \section{Aufgaben} Der Injector ist der zentrale Baustein des Route Injection Service, der die Möglichkeit bietet, mittels \ac{BGP} Communities, Routen in das Netzwerk zu injizieren. Der Injector erfüllt dabei eine Reihe von wesentlichen Aufgaben: \vspace{1cm} Zuallererst ist der Injector für die Konvertierung der von der \ac{API} empfangenen Routen in eine für den Router (Bird) verständliche Konfigurationsdatei verantwortlich. Diese Konvertierung ist von entscheidender Bedeutung, um die Weiterleitung der Routen an den Router in einem kompatiblen Format sicherzustellen. Während die Validierung der Präfixe und Communities von der \ac{API} Komponente übernommen wird, hat der Injector eine eigene Validierung für Routen, welchen über den Emergency-Mode angegeben werden, da hier die \ac{API} Komponente überbrückt wird. Bei auftretenden Konflikten oder Unstimmigkeiten kann der Injector angemessene Maßnahmen ergreifen, um die Integrität der anderen Komponenten und schlussendlich des Netzwerks, zu gewährleisten. Ein wichtiger Aspekt ist auch die aktive Kommunikation des Injectors mit dem Router (Bird). Diese Kommunikation erfolgt, um die generierten Konfigurationsänderungen effektiv zu übertragen und sicherzustellen, dass die injizierten Routen nahtlos in das Routing-Protokoll des Routers integriert werden. Schließlich stellt der Injector durch präzises loggen sicher, dass im Falle eines Fehlers, oder im schlimmsten Fall, bei einem Absturz der Komponente, Ereignisse festgehalten werden. Zusammenfassend fungiert der Injector als entscheidende Schnittstelle, die die Funktionen der \ac{API} und des Routers miteinander verbindet. Mit seiner intelligenten Konvertierung und Verwaltung von Routen durch \ac{BGP} Communities gewährleistet er, dass die gewünschten Routing-Änderungen präzise und effizient im \ac{BGP}-Netzwerk implementiert werden. \newpage \section{Umsetzung} \subsection{Generieren der Config Files für Bird} %TODO RNDMISC-410 Für den einfachen Umgang mit Routen wurde für die \verb|routes| Variable eine Python Dataclass angelegt, welche das IP-Präfix, die IP-Version und eine Liste der gesetzen \ac{BGP}-Communities enthält. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:route-dataclass}, captionpos=b, % Caption unter den Code setzen caption={Python Dataclass für Routen Objekte}] @dataclass class Route: prefix: str encode: str communities: list[str] def __init__(self, prefix="", encode="", communities=[]): self.prefix = prefix self.encode = encode self.communities = communities def __str__(self): return f"{self.encode} {self.prefix} {self.communities}" def decode_prefix(self): self.prefix = self.prefix.replace("_", "/") def encode_prefix(self): self.prefix = self.prefix.replace("/", "_") def get_communities(self): communities = self.communities return list(map(lambda com: com.replace(":", ","), communities)) \end{lstlisting} Neben den genannten Feldern, kann die Dataclass auch noch Methoden zur verarbeitung der Felder, ähnlich wie eine normale Klasse beinhalten. Besonders zu betonen ist hier die \verb|get_communities| Methode, welche die Communities in ein von Bird akzeptiertes Format umwandelt. \newpage Die in Consul gespeicherten Routen werden dann periodisch von Consul über dessen eigene \ac{API} abgefragt. Hierfür ist die \verb|parse_consul_values| Methode zuständig. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:parse-consul-values}, captionpos=b, % Caption unter den Code setzen caption={parse\_consul\_values Methode}] def parse_consul_values(values, watched_prefix) -> (list[Route], list[Route]): if not values: return [], [] v4_routes = [] v6_routes = [] for entry in values: route_entry = Route() route_entry.prefix = entry["Key"].split(watched_prefix)[1] json_communities = entry["Value"].decode("utf-8") route_entry.communities = json.loads(json_communities)["communities"] route_entry.encode, route_entry.prefix = route_entry.prefix.split("/") route_entry.decode_prefix() if route_entry.encode == "IPv4": v4_routes.append(route_entry) elif route_entry.encode == "IPv6": v6_routes.append(route_entry) return v4_routes, v6_routes \end{lstlisting} Im Laufe der Methode werden die abgefragten Einträge in Routenobjekte umgewandelt. Um herauszufinden um welche IP Version es sich bei der Route handelt, wird der entsprechende Key des Pfades ausgelesen, da dieser die IP Version mit im Namen trägt. Als Resultat gibt die Methode ein Tupel zurück, wobei eines die IPv4 Routen und das andere die IPv6 Routen sind. Diese Aufteilung ist notwendig, da die in Debian 11 mitgelieferte Version von Bird eine klare Auftrennung dieser fordert. Neuere Versionen von Bird können auch mit beiden IP Versionen gleichzeitig umgehen. Durch die Auftrennung der beiden IP-Versionen, muss auch die Konfigurationsdatei für Bird, zweimal generiert werden. Mit der Methode \verb|generate_bird_files| werden Umgebungsvariablen geladen, welche für die Generierung der Konfiguration benötigt werden. Neben diesen übergibt die Methode auch die Routen als Parameter weiter. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:generate-bird-files}, captionpos=b, % Caption unter den Code setzen caption={generate\_bird\_files Methode}] def generate_bird_files(v4_routes, v6_routes, pybird, pybird6): gen = BirdConfigGenerator() click.echo("Generating and committing config files") gen.bird_config( v4_routes, "route_template.j2", "../config/bird", "v4.conf", os.getenv("ROUTER_ID"), os.getenv("LOCAL_AS"), os.getenv("REMOTE_AS"), os.getenv("BGP_NEIGHBOR"), "ANEXIA Route Injection v4", ) gen.bird_config( v6_routes, "route_template.j2", "../config/bird", "v6.conf", os.getenv("ROUTER_IDv6"), os.getenv("LOCAL_AS"), os.getenv("REMOTE_AS"), os.getenv("BGP_NEIGHBORv6"), "ANEXIA Route Injection v6", ) pybird.commit_config() pybird6.commit_config() click.echo("Done") \end{lstlisting} \newpage Die Methode \verb|bird_config|, welche um eine gute Struktur zu wahren zu einer gesonderten Datei und Klasse angehört, ruft die \verb|get_communities| Methode der Route Dataclass auf, um die Routen in einer von Bird lesbares Format zu wandeln. Hier wurde das Prinzip des `Separation of Concerns` nicht eingehalten. Im Idealfall sollte die Routenlogik und Birdlogik vollkommen voneinander getrennt sein. Durch die Aufteilung des Codes in abgegrenzte Bereiche wird der Code übersichtlicher und leichter verständlich. Entwickler können sich auf einen spezifischen Teil der Anwendung konzentrieren, ohne sich um die Komplexität anderer Teile kümmern zu müssen. Dies erhöht die Wartbarkeit des Programmcodes um ein Vielfaches, da Änderungen eines Teiles wenig bis keinen Einfluss auf einen anderen haben. Eine mögliche Lösung wäre hier, entweder die Communities schon vor der Übergabe an die Birdlogik in ein von Bird lesbares Format zu wandeln, oder dies in der Birdlogik selbst zu tun. Dadurch dass die Birdlogik hier jedoch eine Methode aus der Routenlogik aufruft, wird unnötige Kopplung geschaffen. Dies sollte in einer zukünftigen Revision des Codes gelöst werden. Des Weiteren wird in der Birdlogik die tatsächliche Konfiguration auf das Dateisystem geschrieben. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:bird-config}, captionpos=b, % Caption unter den Code setzen caption={bird\_config Methode}] def bird_config( self, unconverted_routes, route_template, config_path, config_name, router_id=None, local_as=None, remote_as=None, bgb_neighbor=None, description=None, ): converted_routes = [] for route in unconverted_routes: route.communities = route.get_communities() converted_routes.append(route) target_file = self.__prepare_config_path(config_path, config_name) self.__write_config_file( target_file, converted_routes, route_template, router_id, local_as, remote_as, bgb_neighbor, description, ) \end{lstlisting} Um die Routen an den Bird Routing Daemon übermitteln zu können, müssen diese erst in eine für Bird verständliche Konfigurationsdatei umgewandelt werden. Zur Realisierung wird die Jinja2 Templating Engine verwendet, da diese die Möglichkeit schafft, alle Eigenschaften des Injectors dynamisch zu konfigurieren. Somit kann der tatsächliche Code aller Injectoren identisch sein, und Variable Eigenschaften wie z.B. die \verb|router_id| oder der \ac{BGP}-Peering Nachbar können beim Ausrollen festgelegt werden. \begin{lstlisting}[%language=tex, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:route-template}, captionpos=b, % Caption unter den Code setzen caption={Jinja Template zur Konfigurationsgenerierung}] protocol static injected_routes { {% for route in routes %} route {{route.prefix}} via {{router_id}} { {% for community in route.communities %} bgp_community.add(({{community}})); {% endfor %} }; {% endfor %} } protocol bgp Route_Injection { description "{{description}}"; local as {{local_as}}; neighbor {{bgp_neighbor}} as {{remote_as}}; next hop self; export filter { if proto = "injected_routes" then accept; reject; }; } \end{lstlisting} In diesem Template finden sich einige Variablen \begin{itemize} \item \verb|routes| (Python Liste mit Routen Elementen) \item \verb|router_id| (IPv4 Addresse des Injectors) \item \verb|local_as| (Lokales \ac{ASN}) \item \verb|remote_as| (Nachbar \ac{ASN}) \item \verb|bgp_neighbor| (Nachbar \ac{BGP}-Router IPv4 Adresse) \item \verb|description| (Beschreibung des Protokolls) \end{itemize} wieder, welche entweder im Code oder dynamisch beim Ausrollen, also ausrollen des Injectors gesetzt werden müssen. Jinja kann auch mit Listen und verschachtelten Listen umgehen, was bei der \verb|routes| Variable zum Einsatz kommt. Jinja kann dann über die Liste der Routenobjekte iterieren und für jede Route einen gesonderten Eintrag mit den jeweiligen \ac{BGP}-Communities erstellen. Folglich ein Beispiel einer möglichen Konfiguration. \acp{ASN} und \verb|router_id|s können hier entweder als Umgebungsvariable oder von einer .env Datei geladen werden. Die Routen werden dynamisch während der Programmlaufzeit angegeben, konvertiert und konfiguriert. \begin{lstlisting}[%language=tex, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:bird-configuration-example}, captionpos=b, % Caption unter den Code setzen caption={Beispiel einer Konfigurationsdatei}] protocol static injected_routes { route 1.1.1.1/32 via 172.20.0.5 { bgp_community.add((47147,3200)); bgp_community.add((12345,12345)); }; } protocol bgp Route_Injection { description "ANEXIA Route Injection v4"; local as 64701; neighbor 172.20.0.6 as 65001; next hop self; export filter { if proto = "injected_routes" then accept; reject; }; \end{lstlisting} \newpage Somit können nun Konfigurationsdateien für Bird erstellt werden. Sollte jedoch während dem Rendern des Templates ein Fehler auftreten, kann es passieren, dass eine inkorrekte oder gar keine Konfigurationsdatei generiert wird. Dies könnte einen negativen Einfluss auf die Operation des Service haben und muss somit verhindert werden. Um das Problem zu verhindern, wird die Konfiguration erst in eine temporäre Datei geschrieben. Wenn dies erfolgreich war, dann wird die temporäre Datei umbenannt und in das echte Konfigurationsverzeichnis geschoben. Da hier eine Datei überschrieben statt angepasst wird, gehen Dateiberechtigungen verloren und müssen neu gesetzt werden. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:write-config-file}, captionpos=b, % Caption unter den Code setzen caption={\_\_write\_config\_file Methode}] def __write_config_file( self, target_path, routes, template, router_id=None, local_as=None, remote_as=None, bgp_neighbor=None, description=None, ): template = self.env.get_template(template) with NamedTemporaryFile(delete=False, mode="w") as conf: conf.write( template.render( routes=routes, router_id=router_id, local_as=local_as, remote_as=remote_as, bgp_neighbor=bgp_neighbor, description=description, ) ) try: os.chmod(conf.name, 0o660) shutil.move(conf.name, target_path) except Exception as e: echo(e) os.remove(conf.name) \end{lstlisting} \newpage \subsection{Status der Routen von Bird abfragen} %TODO RNDMISC-361 \subsubsection{Evaluation der pybird Bibliothek} Da die entwickelte \ac{API} über einen Status Endpunkt verfügt, welcher letztendlich von der Anexia Engine abgerufen wird, muss auch der Injektor die benötigten Statusinformationen zur Verfügung stellen. Hierfür wurde evaluiert, welche Python Bibliothek sich am besten zu diesem Zwecke eignet. Die Entscheidung für die Verwendung der `pybird` Bibliothek wurde aus folgenden Gründen getroffen: \begin{enumerate} \item Funktionalität: Die `pybird` Bibliothek wurde speziell dafür entwickelt mit dem Bird Routing Daemon zu interagieren. \item Direkte Socket Anbindung: `pybird` unterstützt die direkte Kommunikation mit dem Bird Control Socket, was eine erleichterte Kommunikation ermöglicht. \item Aktualisierung und Wartung: Da die `pybird` Bibliothek aktiv gepflegt wird, kann sichergestellt werden, dass sie auch mit zukünftigen Versionen des Bird Routing Daemons kompatibel sein wird. Des Weiteren kann so auch sichergestellt werden, dass das Route Injection Project sich auch in der Zukunft noch auf diese Bibliothek verlassen kann. \item Open-Source: Durch den offenen Quellcode, kann sichergestellt werden, dass der Code keine Malware/Spyware enthält. Sollte es nötig sein, kann der Quellcode der Bibliothek geforked, und auf die Bedürfnisse der Anexia angepasst werden. \end{enumerate} Über die Methode \verb|get_routes| der \verb|PyBird| Klasse können die von Bird übernommenen Routen abgefragt werden. Als Parameter kann das Präfix der Route angegeben werden, sodass die Ausgabe auf nur dieses Präfix beschränkt wird. Pybird gibt die Ausgabe dann in folgendem Format zurück: \begin{lstlisting}[language=tex, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:pybird-raw-output}, captionpos=b, % Caption unter den Code setzen caption={Unverarbeitete Ausgabe von Pybird}] [{'community': '65535:65281', 'prefix': '1.2.3.4/32', 'peer': '172.20.0.3', 'interface': 'eth0', 'source': 'injected_routes', 'time': '13:37:47'}] \end{lstlisting} Von dieser Ausgabe wird jedoch nur der Teil, welcher die Communities betrifft benötigt. Folglich muss die Ausgabe noch im Code angepasst werden. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:respond-state-to-consul}, captionpos=b, % Caption unter den Code setzen caption={respond\_state\_to\_consul Methode}] def respond_state_to_consul( consul: ckv, pybird: PyBird, route: Route, injector_id: str ) -> None: state = pybird.get_routes(prefix=route.prefix) route.encode_prefix() try: actual_communities = state[0].get("community", "").split(" ") except IndexError: actual_communities = [] expected_communities = list(route.communities) state = get_bird_communities(expected_communities, actual_communities) state = json.dumps({"communities": state}) try: consul.kv.put( f"v1/state/{injector_id}/{route.encode}/{route.prefix}", state, ) except requests.exceptions.ConnectionError: click.echo("Lost consul while reporting route :c") return route.decode_prefix() click.echo(f"Route {route.prefix} with state {actual_communities} pushed to consul") \end{lstlisting} Um den Status zu bestimmen, werden die Communities, welche im Routenobjekt abgespeichert sind, mit den Communities welche von Bird zurückgegeben wurde verglichen. Stimmen diese überein, so kann davon ausgegangen werden, dass Bird alle Communities akzeptiert hat und an den Nachbar Router übermitteln kann. Sollte es Abweichungen zwischen den Communities geben, bedeutet dies, dass noch nicht alle Communities von Bird akzeptiert wurden. Als Folge dessen werden auch nur die aktuell in Bird eingetragen Communities zurück an Consul übermittelt. Die \ac{API}-Komponente des Route Injection Service fragt dann den in Consul eingetragenen Status ab und bestimmt dann selbst, ob der gesamte Prozess erfolgreich, noch im Gange oder fehlerhaft war. Dies wird dann von der Anexia Engine interpretiert und ist für den Nutzer sichtbar. \subsection{Realisierung des Heartbeats} Um sicherzustellen, dass die API den aktuellen Status der online verfügbaren Injektoren erfassen kann, verwenden die Injektoren ein sogenanntes `Heartbeat`-System, das seine Aktivität in Consul signalisiert. Dieses Heartbeat wird in Form eines Wertes (Value) in Consul gemeldet. Dieser Prozess ermöglicht es der API, den Zustand der einzelnen Injektoren zu überwachen und sicherzustellen, dass sie ordnungsgemäß funktionieren. Jeder Injektor meldet seinen Status durch das Schreiben eines Wertes (Value) in einen spezifischen Schlüssel-Wert-Pfad in Consul. Dieser Pfad lautet: \verb|v1/state//heartbeat|. Hierbei steht \verb|| für die eindeutige Kennung des Injektors. Der Wert (Value), der in den oben genannten Schlüssel-Wert-Pfad geschrieben wird, hat den Inhalt `\{\}`, was auf ein leeres JSON-Objekt hinweist. Dieses leere Objekt dient als Platzhalter und signalisiert der API, dass der Injektor aktiv ist und seinen Heartbeat meldet. Der gemeldete Wert (Value) hat eine \ac{TTL} von 10 Sekunden. Dies bedeutet, dass nachdem der Injektor seinen Heartbeat gemeldet hat, der Wert für 10 Sekunden in Consul bestehen bleibt. Wenn innerhalb dieses Zeitraums keine weiteren Heartbeats gemeldet werden, wird der Wert automatisch aus Consul entfernt. Durch das Heartbeat-System kann die API regelmäßig aktualisierte Informationen erhalten, welche Injektoren online und funktionsfähig sind. \vspace{1cm} Um den Heartbeat im Programmcode möglichst modular zu realisieren wurde hierfür eine eigene Methode erstellt. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:create-heartbeat}, captionpos=b, % Caption unter den Code setzen caption={create\_heartbeat Methode}] def create_heartbeat(consul, injector_id): session_id = consul.session.create(behavior="delete", ttl=10) consul.kv.put( key=f"v1/state/{injector_id}/heartbeat", value="{}", acquire=session_id ) return session_id \end{lstlisting} Nach dem initialen Anlegen des Heartbeateintrages wird dieser alle fünf Sekunden erneuert und sicherzustellen, dass die \ac{TTL} des Eintrages nicht abläuft. \subsection{Emergency-Mode} %TODO RNDMISC-363 Um sicherzustellen, dass der Route Injection Service auch in Szenarien von Netzwerkproblemen zwischen der \ac{API} und den Injektoren effizient arbeiten kann, sei es für das Hinzufügen, Ändern oder Löschen von Routen, wurde eine maßgebliche Funktion eingeführt, die als Emergency-Mode, bzw. Notfallmodus bekannt ist. Diese Funktion wurde entwickelt, um direkten Zugriff auf die Injektoren zu ermöglichen und Routenverwaltungsvorgänge über die Kommandozeile durchzuführen. Der Emergency-Mode fungiert als eine Art Sicherheitsvorkehrung, die sicherstellt, dass die Verfügbarkeit und Funktionalität des Dienstes aufrechterhalten werden kann, selbst wenn die übliche Kommunikation zwischen der \ac{API} und den Injektoren temporär gestört ist. Der Namensteil `Mode` lässt vermuten, dass es sich um einen tatsächlichen Operationsmodus handelt. Dies ist allerdings nicht ganz korrekt. Der Emergency-Mode ist eher als Funktionalitätserweiterung zu sehen und kann selbst dann aktiviert werden, wenn die Kommunikation zwischen \ac{API} und Injector intakt ist. Dies ist insbesondere nützlich, wenn dringende Änderungen an den Routingeinstellungen erforderlich sind, die nicht auf die normale Kommunikation warten können. Um Zugriff auf die Kommandozeile zu erhalten, muss ein Nutzer sich über \ac{SSH} auf den Injector einloggen. Firmeninterne Automatismen stellen sicher, dass nur befugte Nutzer Zugriff auf das System haben. Zur Gewährleistung der Integrität des Service müssen die vom Nutzer eingegebene Routen validiert und auf Ihre Korrektheit überprüft werden. In der Regel wird dies von der \ac{API} übernommen, jedoch werden im Notfallmodus die Routen direkt in den Injector eingespeist, und die Validierung der \ac{API} wird umgangen. Daher muss diese vom Injector selbst durchgeführt werden. \newpage \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:validate-route}, captionpos=b, % Caption unter den Code setzen caption={Methode zur Validierung von Routen}] def validate_route(prefix: str, communities=None) -> Route: route = Route() try: route.prefix = str(ipaddress.ip_network(prefix)) except ValueError: raise click.exceptions.BadParameter("Route prefix is invalid") if ":" in route.prefix: route.encode = "IPv6" else: route.encode = "IPv4" if communities: communities = communities.split(",") for community in communities: community_parts = community.split(":") if len(community_parts) != 2: raise click.exceptions.BadParameter( f"{community} is not a valid BGP community" ) try: if not int(community_parts[0]) in range(1, 65535) or not int( community_parts[1] ) in range(1, 65535): raise click.exceptions.BadParameter( f"{community} is not a valid BGP community" ) except ValueError: raise click.exceptions.BadParameter( f"{community} contains invalid integer value" ) route.communities = list(communities) return route \end{lstlisting} Der Zweck ist, BGP-Routen, primär in Bezug auf deren Präfixe und Communities zu validieren. Die Methode akzeptiert ein Präfix als obligatorisches Argument und optional eine Liste von Communities als Zeichenfolge. Das Hauptziel dieser Funktion ist es, sicherzustellen, dass die angegebenen Informationen den \ac{BGP}-Anforderungen entsprechen und gültig sind. Zuerst wird ein neues Routenobjekt erstellt, das als Container für die validierten Daten dient. Die Funktion versucht dann, den übergebenen Präfix als IP-Netzwerk zu interpretieren. Bei einer ungültigen Eingabe wird eine `BadParameter`-Exception ausgelöst. Das Präfix wird analysiert, um festzustellen, ob es sich um ein IPv4- oder IPv6-Präfix handelt. Dies wird im `encode`-Attribut des Routenobjekts vermerkt. Im Fall von übergebenen Communities werden diese analysiert und validiert. Jede Community wird auf ihre Struktur überprüft, und die einzelnen Teile werden auf ihre Gültigkeit im Hinblick auf \ac{ASN} und Wertigkeit geprüft. Fehlerhafte Communities führen zu entsprechenden `BadParameter`-Exceptions. Abschließend werden die validierten Informationen, einschließlich Präfix und Communities, im Routenobjekt gespeichert. Die Funktion gibt dieses Objekt zurück, das nun die validierten Daten enthält. Zur Vereinfachung der Interaktionen mit der Kommandozeile wird die Bibliothek `click` verwendet. Durch diese können Exceptions leicht an den Benutzer übermittelt werden, und Tests können einfach gestaltet werden. Der Operator welcher letztendlich den Emergency Mode bedienen wird, hat zwei Eingabemöglichkeiten: \begin{itemize} \item add-route \item delete-route \end{itemize} Wobei `<>` für Platzhalter des entsprechenden Parameters stehen. Eine Möglichkeit, schon existierende Routen zu updaten bietet der Emergency Mode nicht. Routen welche über den Emergency Mode hinzugefügt wurde, haben immer Vorrang gegenüber Routen, welche über Consul geladen wurden. Eine weitere Anforderung an den Emergency Mode war, dass Routen auch nach Reboot des Injectors erhalten bleiben. Dies forderte, dass Routen auf einer Weise im Dateisystem erhalten werden. Um dies zu Realisieren bestünde die Möglichkeit eine Datenbank wie `sqlite` zu nutzen. Eine einfachere Lösung dieses Problems war es jedoch, die Routen als \ac{JSON} in eine Datei zu schreiben. Die schon bei den Konfigurationsdateien für Bird, wurden IPv4 und IPv6 aus demselben Grund getrennt. Um die Konsistenz und Integrität dieser Dateien, auch `Emergency Files` genannt zu gewährleisten, wurde ein Filelock gesetzt. Zur Vermeidung des Dirty read Problems, welches in der Vorlesung Datenbanken erläutert wurde, wurde das Filelock sowohl für Schreib- als auch für Lesevorgänge gesetzt. So kann ein zweiter Prozess das Emergency File erst lesen, wenn der erste Prozess den Schreibvorgang abgeschlossen hat. Dies dient nicht nur zur Mehrbenutzersynchronisation von mehreren Menschen, sondern hauptsächlich, dass der Hauptprozess nicht versucht das Emergency File zu lesen, während ein Operator mittels des Emergency Mode Änderungen vornimmt. Da Python, beziehungsweise die benutze \ac{JSON} Bibliothek Probleme damit hatte verschachtelte \ac{JSON}s zu de- und enkodieren, wurde eine weitere Dataclass angelegt. Diese Dataclass dient nur als Container, um eine Liste an Routenobjekten anzulegen. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:route-container}, captionpos=b, % Caption unter den Code setzen caption={Route Container Dataclass}] @dataclass_json @dataclass class RouteContainer: routes: list[Route] def __init__(self, routes): self.routes = routes def __str__(self): return f"{self.routes}" \end{lstlisting} Zu Beginn des Programmstarts werden die Pfade der Lockfiles und Emergency Files innerhalb des Docker Containers festgelegt: \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:path-declaration}, captionpos=b, % Caption unter den Code setzen caption={Deklaration der Dateipfade}] emergency_file_v4 = "/var/lib/route_injector/emergency_route_v4.json" emergency_file_v6 = "/var/lib/route_injector/emergency_route_v6.json" lock_file_v4 = "/var/lib/route_injector/emergency_route_v4.lock" lock_file_v6 = "/var/lib/route_injector/emergency_route_v6.lock" \end{lstlisting} \newpage Die Methode welche beim Aufruf von \verb|add-route| über die Kommandozeile aufgerufen wird lässt sich wie folgt darstellen: \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:add-route}, captionpos=b, % Caption unter den Code setzen caption={add\_route Methode}] @click.argument("communities") @click.argument("prefix") @cli.command() def add_route(prefix, communities): route = validate_route(prefix, communities) emergency_file = emergency_file_v4 lockfile = FileLock(lock_file_v4) if route.encode == "IPv6": emergency_file = emergency_file_v6 lockfile = FileLock(lock_file_v6) with lockfile.acquire(): current_routes = read_emergency_file(emergency_file) new_routes = find_and_remove_in_list(current_routes, route) new_routes.append(route) route_container = RouteContainer(new_routes) write_emergency_file(route_container, emergency_file) \end{lstlisting} Zuerst wird über den erwähnten Validierungsprozess sichergestellt, dass die vom Nutzer eingegeben Route eine valide Route ist. Über eine if Abfrage wird geprüft, ob das Präfix der eingegebenen Route ein IPv6 Präfix ist. Ist das der Fall, dann wird das entsprechende Emergency File und Lockfile einer Variablen zugewiesen. Anschließend wird das Filelock auf das entsprechende Emergency File gesetzt, um sicherzustellen, dass keine weiteren Prozesse auf das File zugreifen können. Im Folgenden werden die schon im Emergency File enthaltenen Routen mit den neu hinzugefügten verglichen. Sollte eine Route hinzugefügt werden, wessen Präfix schon im aktuellen Emergency File enthalten ist, wird diese über die \verb|find_and_remove_in_list| entfernt. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:find-and-remove-in-list}, captionpos=b, % Caption unter den Code setzen caption={find\_and\_remove\_in\_list Methode}] def find_and_remove_in_list(route_list: list, list_element: Route): for element in route_list: if element.prefix == list_element.prefix: route_list.remove(element) return route_list \end{lstlisting} Die neue Route wird danach der Liste von Routen hinzugefügt, und über die RouteContainer Dataclass wieder zu einer verschachtelten \ac{JSON} konvertiert. Zum Löschen von Routen aus den Emergency Files, gibt es die \verb|delete_route| Methode, welche sich maßgeblich dadurch unterscheidet, dass sie keine \ac{BGP}-Communities als Parameter benötigt, sondern lediglich das Routenpräfix. Infolgedessen, fehlt in dieser Methode auch der Teil, welcher die neue Route der Routenliste hinzufügt, da hier nur die Route entfernt werden muss. Da das Lesen und Schreiben der Files mehrmals im Programmcode geschieht, wurde hierfür jeweils eine Methode geschrieben um Codeduplizierung möglichst zu vermeiden und das \ac{DRY} Prinzip einzuhalten. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:read-emergency-file}, captionpos=b, % Caption unter den Code setzen caption={read\_emergency\_file Methode}] def read_emergency_file(emergency_route_file: str) -> list: if not os.path.exists(emergency_route_file): return [] with open(emergency_route_file, "r") as emergency_route: json_routes = emergency_route.read() routes_from_file = RouteContainer.from_json(json_routes).routes return routes_from_file \end{lstlisting} \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:write-emergency-file}, captionpos=b, % Caption unter den Code setzen caption={write\_emergency\_file Methode}] def write_emergency_file(routes: RouteContainer, emergency_route_file: str): with NamedTemporaryFile(delete=False, mode="w") as tmp_emergency_route_file: tmp_emergency_route_file.write(routes.to_json()) try: shutil.move(tmp_emergency_route_file.name, emergency_route_file) except Exception as e: click.echo(e) os.remove(tmp_emergency_route_file.name) \end{lstlisting} \newpage \section{Testen} Unit-Testing ist ein essenzieller Bestandteil der Entwicklung des Route Injection Service. Es dient dazu, die Qualität, Verlässlichkeit und Korrektheit des Codes sicherzustellen. Dieser Abschnitt erläutert die Bedeutung und den Prozess des Unit-Testings für den entwickelten Service. Die Verwendung von Unit-Tests bietet mehrere Vorteile. Sie ermöglichen die frühzeitige Erkennung von Fehlern und Unstimmigkeiten im Code. Darüber hinaus dienen sie als Form der Dokumentation und unterstützen Refactoring-Bemühungen, um sicherzustellen, dass Änderungen den erwarteten Verhalten nicht beeinträchtigen. Unit-Tests schaffen Vertrauen in die Software, da sie Fehler und Probleme offenlegen, bevor sie zu potenziell schwerwiegenden Problemen werden können. Im Fall des Route Injection Service wurde das pytest als Testwerkzeug eingesetzt. Die Testabdeckung ist von zentraler Bedeutung, da sie sicherstellt, dass jeder relevante Teil des Codes getestet wird. Über den gesamten Route Injection Service wurde hier eine Codeabdeckung von 97\% erreicht. Die Automatisierung von Unit-Tests gewährleistet, dass Tests konsistent und wiederholbar durchgeführt werden können. Dies ist besonders wichtig, wenn Codeänderungen vorgenommen werden, da Tests automatisch in die CI/CD-Pipeline integriert werden und bei jeder Änderung ausgeführt werden. Zusätzlich zur Testautomatisierung wird ein Testbericht erstellt, welcher als Artifact in GitLab hochgeladen wird. Dies ermöglicht es auch in Zukunft, vergangene Testergebnisse nachvollziehen zu können. Unit Testing ist ein fortlaufender Prozess, der die Qualität und Stabilität des Route Injection Service sicherstellt. Die regelmäßige Aktualisierung und Erweiterung der Test Suite ist unerlässlich, um sicherzustellen, dass der Service den sich ändernden Anforderungen gerecht wird und robust bleibt. Insgesamt trägt das Unit Testing maßgeblich zur Zuverlässigkeit und Leistungsfähigkeit des Route Injection Service bei. Die Überprüfung der Funktionalität einer einzelnen Methode ist eines der Hauptaufgaben von Unit-Testing. Da viele Methoden jedoch voneinander oder anderen Komponenten abhängig sind, müssen diese über `Mockings` ersetzt werden. Mittels der Python `unittest` Bibliothek ist dies sehr einfach möglich. \newpage Beim Erstellen der Tests wurde nach der \ac{AAA} Struktur gearbeitet. \begin{lstlisting}[language=python, frame=single, % Ein Rahmen um den Code framexleftmargin=15pt, % Rahmen link von den Zahlen style=algoBericht, label={lst:test-respond-state-to-consul}, captionpos=b, % Caption unter den Code setzen caption={Test für respond\_state\_to\_consul Methode}] class TestRespondStateToConsul(TestCase): @mock.patch("injector.PyBird") @mock.patch("injector.get_bird_communities") @mock.patch("injector.ckv") def test_respond_state_to_consul( self, mock_consul, mock_get_bird_communities, mock_pybird ): mock_pybird.get_routes.return_value = [ { "community": "47147:3200 12345:12345", "prefix": "1.1.1.1/32", } ] mock_get_bird_communities.return_value = ["47147:3200", "12345:12345"] route = Route( prefix="1.1.1.1/32", encode="IPv4", communities='{"communities": ["47147:3200", "12345:12345"]}', ) injector_id = "0" respond_state_to_consul(mock_consul, mock_pybird, route, injector_id) mock_consul.kv.put.assert_called_once_with( f"v1/state/{injector_id}/IPv4/1.1.1.1_32", '{"communities": ["47147:3200", "12345:12345"]}', ) \end{lstlisting} Die Mockings werden hier als Decorator übergeben, da die Methode anderen Methoden des Injectors aufruft und externe Abhängigkeiten wie die Consul oder Pybird Bibliothek besitzt. \textbf{Arrange (Vorbereiten):} In Zeile 8--21 werden die Rückgabewerte der Mockings und Parameter der zu überprüfenden Methode festgelegt. \textbf{Act (Ausführen):} In Zeile 22 wird die zu überprüfende Methode aufgerufen. \textbf{Assert (Überprüfen):} In Zeile 24--27 wird das tatsächliche Ergebnis mit dem zu erwartenden Ergebnis überprüft. Wenn die Ergebnisse ungleich sind, schlägt der Test und somit auch die Pipeline in Gitlab fehl. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% \endinput %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%