Syncing RIPE, ARIN, and APNIC objects with a custom Ansible module
Vincent Bernat
10-minute read
Also available in
Filed under
Attachment:
The 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 sets up 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 side effects. 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 you
to manually delete and recreate the updated object.6 However,
this module should automate 95% of your needs.