Synchroniser des objets RIPE, ARIN et APNIC avec un module Ansible sur mesure
Vincent Bernat
L’Internet est divisé en cinq registres Internet régionaux : AFRINIC, ARIN, APNIC, LACNIC et RIPE. Chacun de ces RIR gère un registre de routage Internet. Un tel IRR permet de publier des informations sur le routage des ressources Internet1. Les opérateurs l’utilisent pour déterminer le propriétaire d’une adresse IP et pour construire et maintenir des filtres de routage. Pour que vos routes soient largement acceptées, il est important de tenir à jour les préfixes que vous annoncez dans un IRR.
Il existe deux outils courants pour interroger cette base de données :
whois
et bgpq4
. Le premier vous permet d’effectuer une requête
avec le protocole WHOIS :
$ whois -h whois.ripe.net -- '-BrG 2a0a:e805:400::/40' […] inet6num: 2a0a:e805:400::/40 netname: FR-BLADE-CUSTOMERS-DE country: DE geoloc: 50.1109 8.6821 admin-c: BN2763-RIPE tech-c: BN2763-RIPE status: ASSIGNED mnt-by: fr-blade-1-mnt remarks: synced with cmdb created: 2020-05-19T08:04:58Z last-modified: 2020-05-19T08:04:58Z source: RIPE route6: 2a0a:e805:400::/40 descr: Blade IPv6 - AMS1 origin: AS64476 mnt-by: fr-blade-1-mnt remarks: synced with cmdb created: 2019-10-01T08:19:34Z last-modified: 2020-05-19T08:05:00Z source: RIPE
Le second permet de construire des filtres de routage en utilisant les informations contenues dans la base de données IRR :
$ bgpq4 -6 -S RIPE -b AS64476 NN = [ 2a0a:e805::/40, 2a0a:e805:100::/40, 2a0a:e805:300::/40, 2a0a:e805:400::/40, 2a0a:e805:500::/40 ];
Il n’y a pas de module disponible sur Ansible Galaxy pour gérer ces objets. Chaque IRR a différentes façons d’être mis à jour. Certains RIR proposent une API, mais d’autres non. Si l’on se limite au RIPE, à l’ARIN et à l’APNIC, la seule méthode commune de mise à jour des objets est la mise à jour par courrier électronique, authentifié par un mot de passe ou une signature PGP2. Écrivons un module Ansible à cet effet !
Note
Je vous recommande de lire « Écrire un module Ansible sur mesure » en introduction, ainsi que « Synchroniser des tables MySQL » pour un premier exemple plus didactique.
Code#
Le module prend une liste d’objets RPSL à synchroniser et renvoie le contenu d’un courrier électronique pour mise à jour si un changement est nécessaire :
- name: prepare RIPE objects irr_sync: irr: RIPE mntner: fr-blade-1-mnt source: whois-ripe.txt register: irr
Prérequis#
Le fichier source doit être un ensemble d’objets à synchroniser en utilisant le langage RPSL. Il s’agirait du même contenu que celui que vous enverriez manuellement par courrier électronique. Tous les objets doivent être gérés par le même mainteneur, également fourni en paramètre.
La signature3 et l’envoi du résultat ne relèvent pas de la responsabilité de ce module. Vous avez besoin de deux tâches supplémentaires à cette fin :
- name: sign RIPE objects shell: cmd: gpg --batch --local-user noc@example.com --clearsign stdin: "{{ irr.objects }}" register: signed check_mode: false changed_when: false - name: update RIPE objects by email mail: subject: "NEW: update for RIPE" from: noc@example.com to: "auto-dbm@ripe.net" cc: noc@example.com host: smtp.example.com port: 25 charset: us-ascii body: "{{ signed.stdout }}"
Vous devez aussi autoriser la clé PGP utilisée pour signer les mises
à jour en créant un objet key-cert
et en l’ajoutant comme méthode
d’authentification pour l’objet mntner
correspondant :
key-cert: PGPKEY-A791AAAB certif: -----BEGIN PGP PUBLIC KEY BLOCK----- certif: certif: mQGNBF8TLY8BDADEwP3a6/vRhEERBIaPUAFnr23zKCNt5YhWRZyt50mKq1RmQBBY […] certif: -----END PGP PUBLIC KEY BLOCK----- mnt-by: fr-blade-1-mnt source: RIPE mntner: fr-blade-1-mnt […] auth: PGPKEY-A791AAAB mnt-by: fr-blade-1-mnt source: RIPE
Définition du module#
En se basant sur le squelette présenté dans l’article précédent, nous définissons le module :
module_args = dict( irr=dict(type='str', required=True), mntner=dict(type='str', required=True), source=dict(type='path', required=True), ) result = dict( changed=False, ) module = AnsibleModule( argument_spec=module_args, supports_check_mode=True )
Obtenir les objets existants#
Pour récupérer les objets existants, la commande whois
recherche
tous les objets attachés au mainteneur fourni en paramètre du module.
# Per-IRR variations: # - whois server whois = { 'ARIN': 'rr.arin.net', 'RIPE': 'whois.ripe.net', 'APNIC': 'whois.apnic.net' } # - whois options options = { 'ARIN': ['-r'], 'RIPE': ['-BrG'], 'APNIC': ['-BrG'] } # - objects excluded from synchronization excluded = ["domain"] if irr == "ARIN": # ARIN does not return these objects excluded.extend([ "key-cert", "mntner", ]) # Grab existing objects args = ["-h", whois[irr], "-s", irr, *options[irr], "-i", "mnt-by", module.params['mntner']] proc = subprocess.run(["whois", *args], capture_output=True) if proc.returncode != 0: raise AnsibleError( f"unable to query whois: {args}") output = proc.stdout.decode('ascii') got = extract(output, excluded)
La première partie du code introduit des constantes spécifiques à
chaque IRR : le serveur à interroger, les options à fournir à la
commande whois
et les objets à exclure de la synchronisation. La
seconde partie invoque la commande whois
qui récupère tous les
objets dont le champ mnt-by
correspond au mainteneur fourni. Voici
un exemple de sortie :
$ whois -h whois.ripe.net -- '-s RIPE -BrG -i mnt-by fr-blade-1-mnt' […] inet6num: 2a0a:e805:300::/40 netname: FR-BLADE-CUSTOMERS-FR country: FR geoloc: 48.8566 2.3522 admin-c: BN2763-RIPE tech-c: BN2763-RIPE status: ASSIGNED mnt-by: fr-blade-1-mnt remarks: synced with cmdb created: 2020-05-19T08:04:59Z last-modified: 2020-05-19T08:04:59Z source: RIPE […] route6: 2a0a:e805:300::/40 descr: Blade IPv6 - PA1 origin: AS64476 mnt-by: fr-blade-1-mnt remarks: synced with cmdb created: 2019-10-01T08:19:34Z last-modified: 2020-05-19T08:05:00Z source: RIPE […]
Le résultat est transmis à la fonction extract()
. Elle analyse et
normalise les résultats dans un dictionnaire qui met en correspondance
les noms d’objets et les objets. Nous stockons le résultat dans la
variable got
.
def extract(raw, excluded): """Extract objects.""" # First step, remove comments and unwanted lines objects = "\n".join([obj for obj in raw.split("\n") if not obj.startswith(( "#", "%", ))]) # Second step, split objects objects = [RPSLObject(obj.strip()) for obj in re.split(r"\n\n+", objects) if obj.strip() and not obj.startswith( tuple(f"{x}:" for x in excluded))] # Last step, put objects in a dict objects = {repr(obj): obj for obj in objects} return objects
RPSLObject()
est la classe qui permet de normaliser et comparer les
objets. Regardez le code du module pour plus de détails.
>>> output=""" ... inet6num: 2a0a:e805:300::/40 ... […] ... """ >>> pprint({k: str(v) for k,v in extract(output, excluded=[])}) {'<Object:inet6num:2a0a:e805:300::/40>': 'inet6num: 2a0a:e805:300::/40\n' 'netname: FR-BLADE-CUSTOMERS-FR\n' 'country: FR\n' 'geoloc: 48.8566 2.3522\n' 'admin-c: BN2763-RIPE\n' 'tech-c: BN2763-RIPE\n' 'status: ASSIGNED\n' 'mnt-by: fr-blade-1-mnt\n' 'remarks: synced with cmdb\n' 'source: RIPE', '<Object:route6:2a0a:e805:300::/40>': 'route6: 2a0a:e805:300::/40\n' 'descr: Blade IPv6 - PA1\n' 'origin: AS64476\n' 'mnt-by: fr-blade-1-mnt\n' 'remarks: synced with cmdb\n' 'source: RIPE'}
Comparer avec les objets souhaités#
Construisons maintenant le dictionnaire wanted
en réutilisant la
fonction extract()
:
with open(module.params['source']) as f: source = f.read() wanted = extract(source, excluded)
L’étape suivante est de comparer got
et wanted
pour construire les
différences :
if got != wanted: result['changed'] = True if module._diff: result['diff'] = [ dict(before_header=k, after_header=k, before=str(got.get(k, "")), after=str(wanted.get(k, ""))) for k in set((*wanted.keys(), *got.keys())) if k not in wanted or k not in got or wanted[k] != got[k]]
Retourner les mises à jour#
Le module n’a pas d’effet de bord. S’il y a une différence, nous
retournons les mises à jour à envoyer par courrier électronique. Nous
choisissons d’inclure tous les objets souhaités dans les mises à jour
(contenus dans la variable source
) et de laisser l’IRR ignorer les
objets non modifiés. Nous ajoutons également les objets à supprimer en
ajoutant un attribut delete:
à chacun d’entre eux.
# We send all source objects and deleted objects. deleted_mark = f"{'delete:':16}deleted by CMDB" deleted = "\n\n".join([f"{got[k].raw}\n{deleted_mark}" for k in got if k not in wanted]) result['objects'] = f"{source}\n\n{deleted}" module.exit_json(**result)
Le code complet est disponible sur GitHub. Le module prend
en charge les options --diff
et --check
. Il ne renvoie rien si
aucun changement n’est détecté. Il peut fonctionner avec l’APNIC, le
RIPE et l’ARIN. Il n’est pas parfait : il peut ne pas détecter
certains changements4, il n’est pas capable de modifier des
objets n’appartenant pas au mainteneur fourni5 et certains
attributs refusent les mises à jour, ce qui nécessite de supprimer et
de recréer manuellement l’objet6. Cependant, ce module devrait
automatiser 95% de vos besoins.
-
D’autres IRR existent en dehors de ceux maintenus par les RIR. Le plus connu est RADb. ↩︎
-
L’ARIN abandonne progressivement cette méthode au profit de IRR-online. De plus, les signatures PGP n’ont jamais été supportées en dehors de l’environnement de test. RIPE dispose d’une API, mais les mises à jour par courrier électronique sont toujours prises en charge et il n’est pas prévu de les faire disparaître. L’APNIC prévoit d’exposer une API. ↩︎
-
PGP n’est pas pris en charge par l’ARIN en dehors de l’environnement de test. Seule l’authentification par mot de passe est disponible… 😕 ↩︎
-
Pour l’ARIN, nous ne pouvons pas interroger les objets
key-cert
etmntner
et donc nous ne pouvons pas détecter les changements qui s’y produisent. Il n’est pas non plus possible de détecter les modifications des mécanismes d’authentification d’un objetmntner
. ↩︎ -
L’APNIC n’attribue pas les objets de plus haut niveau au mainteneur associé au propriétaire. ↩︎
-
Pour modifier le statut d’un objet
inetnum
, il faut supprimer et recréer l’objet. ↩︎