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.


  1. D’autres IRR existent en dehors de ceux maintenus par les RIR. Le plus connu est RADb↩︎

  2. 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↩︎

  3. 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… 😕 ↩︎

  4. Pour l’ARIN, nous ne pouvons pas interroger les objets key-cert et mntner 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 objet mntner↩︎

  5. L’APNIC n’attribue pas les objets de plus haut niveau au mainteneur associé au propriétaire. ↩︎

  6. Pour modifier le statut d’un objet inetnum, il faut supprimer et recréer l’objet. ↩︎