Syncing RIPE, ARIN, and APNIC objects with a custom Ansible module
Vincent Bernat
Internet is split into five regional Internet registry: AFRINIC, ARIN, APNIC, LACNIC, and RIPE. Each RIR maintains an Internet Routing Registry. An IRR allows one to publish information about the routing of Internet number resources.1 Operators use this to determine the owner of an IP address and to construct and maintain routing filters. To ensure your routes are widely accepted, it is important to keep the prefixes you announce up-to-date in an IRR.
There are two common tools to query this database: whois
and
bgpq4
. The first one allows you to do a query with the WHOIS
protocol:
$ 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
The second one allows you to build route filters using the information contained in the IRR database:
$ 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 ];
There is no module available on Ansible Galaxy to manage these objects. Each IRR has different ways of being updated. Some RIRs propose an API but some don’t. If we restrict ourselves to RIPE, ARIN, and APNIC, the only common method to update objects is email updates, authenticated with a password or a PGP signature.2 Let’s write a custom Ansible module for this purpose!
Notice
I recommend that you read “Writing a custom Ansible module” as an introduction, as well as “Syncing MySQL tables” for a more instructive example.
Code#
The module takes a list of RPSL objects to synchronize and returns the body of an email update if a change is needed:
- name: prepare RIPE objects irr_sync: irr: RIPE mntner: fr-blade-1-mnt source: whois-ripe.txt register: irr
Prerequisites#
The source file should be a set of objects to sync using the RPSL language. This would be the same content you would send manually by email. All objects should be managed by the same maintainer, which is also provided as a parameter.
Signing3 and sending the result is not the responsibility of this module. You need two additional tasks for this purpose:
- 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 }}"
You also need to authorize the PGP keys used to sign the updates by
creating a key-cert
object and adding it as a valid authentication
method for the corresponding mntner
object:
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
Module definition#
Starting from the skeleton described in the previous article, we define the 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 )
Getting existing objects#
To grab existing objects, we use the whois
command to retrieve all
the objects from the provided maintainer.
# 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)
The first part of the code setup some IRR-specific constants: the
server to query, the options to provide to the whois
command and the
objects to exclude from synchronization. The second part invokes the
whois
command, requesting all objects whose mnt-by
field is the
provided maintainer. Here is an example of output:
$ 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 […]
The result is passed to the extract()
function. It parses and
normalizes the results into a dictionary mapping object names to
objects. We store the result in the got
variable.
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()
is a class enabling normalization and comparison of
objects. Look at the module code for more details.
>>> 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'}
Comparing with wanted objects#
Let’s build the wanted
dictionary using the same structure, thanks
to the extract()
function we can use verbatim:
with open(module.params['source']) as f: source = f.read() wanted = extract(source, excluded)
The next step is to compare got
and wanted
to build the diff
object:
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]]
Returning updates#
The module does not have a side effect. If there is a difference, we
return the updates to send by email. We choose to include all wanted
objects in the updates (contained in the source
variable) and let
the IRR ignore unmodified objects. We also append the objects to be
deleted by adding a delete:
attribute to each of them.
# 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)
The complete code is available on GitHub. The module
supports both --diff
and --check
flags. It does not return
anything if no change is detected. It can work with APNIC, RIPE, and
ARIN. It is not perfect: it may not detect some changes,4 it
is not able to modify objects not owned by the provided
maintainer5 and some attributes cannot be modified, requiring
to manually delete and recreate the updated object.6 However,
this module should automate 95% of your needs.
-
Other IRRs exist without being attached to an RIR. The most notable one is RADb. ↩︎
-
ARIN is phasing out this method in favor of IRR-online. Moreover, PGP signatures were never supported outside the test environment. RIPE has an API available, but email updates are still supported and not planned to be deprecated. APNIC plans to expose an API. ↩︎
-
PGP is not supported by ARIN outside the test environment. You need to use the password method instead… 😕 ↩︎
-
For ARIN, we cannot query
key-cert
andmntner
objects and therefore we cannot detect changes in them. It is also not possible to detect changes to the auth mechanisms of amntner
object. ↩︎ -
APNIC does not assign top-level objects to the maintainer associated with the owner. ↩︎
-
Changing the status of an
inetnum
object requires deleting and recreating the object. ↩︎