diff --git a/bgpwtf/cccampix/BUILD b/bgpwtf/cccampix/BUILD new file mode 100644 index 00000000..0d9fd06a --- /dev/null +++ b/bgpwtf/cccampix/BUILD @@ -0,0 +1,9 @@ +py_binary( + name = "sync", + srcs = [ + "sync.py", + ], + deps = [ + "@pip36//requests", + ], +) diff --git a/bgpwtf/cccampix/sync.py b/bgpwtf/cccampix/sync.py new file mode 100644 index 00000000..31c2d2ed --- /dev/null +++ b/bgpwtf/cccampix/sync.py @@ -0,0 +1,216 @@ +""" +Updates IRR objects in RIPE for the CCCamp IX. + +This, given a mntner password and a list of ASNs taking part in the IXP will +ensure the right IRR objects are present: + + - an aut-num containing import/export RPSL rules + - an as-set containing all member ASs. + + +""" + +import difflib +import string +import sys +import time + +import requests + + +class IRRObject: + """An IRR object from RIPE.""" + TYPE = None + def __init__(self, fields=None): + self.fields = fields or [] + + def add(self, k, v): + self.fields.append((k, v)) + + def render(self): + """Render to IRR format.""" + return '\n'.join('{:16s}{}'.format(k+":", v) for k, v in self.fields) + "\n" + + @classmethod + def from_ripe(cls, v): + """Download this object from the RIPE REST API.""" + if cls.TYPE is None: + raise Exception('cannot fetch untyped IRRObject') + + r = requests.get('https://rest.db.ripe.net/ripe/{}/{}.json'.format(cls.TYPE, v)) + d = r.json() + + obj = d['objects']['object'][0] + assert obj['type'] == cls.TYPE + assert obj['primary-key']['attribute'][0]['value'] == v + + attrs = obj['attributes']['attribute'] + + fields = [] + for attr in attrs: + # Skip metadata. + if attr['name'] in ('created', 'last-modified'): + continue + fields.append((attr['name'], attr['value'])) + + return cls(fields) + + def send_to_ripe(self, password): + """Update this object (or create new) in RIPE using the SyncUpdates API.""" + res = self.render() + res += 'password: {}\n'.format(password) + + data = { + 'DATA': res, + } + r = requests.post('http://syncupdates.db.ripe.net/', files=data) + res = r.text + if 'Modify SUCCEEDED' not in res: + print(res) + raise Exception("Unexpected result from RIPE syncupdates") + + +banner = [ + ('remarks', r".--------------------------------------."), + ('remarks', r"| _ _ __ |"), + ('remarks', r"| | |__ __ _ _ ____ _| |_ / _| |"), + ('remarks', r"| | '_ \ / _` | '_ \ \ /\ / / __| |_ |"), + ('remarks', r"| | |_) | (_| | |_) \ V V /| |_| _| |"), + ('remarks', r"| |_.__/ \__, | .__(_)_/\_/ \__|_| |"), + ('remarks', r"| |___/|_| |"), + ('remarks', r"|--------------------------------------|"), + ('remarks', r"| CCCamp2019 Internet Exchange Point |"), + ('remarks', r"'--------------------------------------'"), + ('remarks', r''), + ('remarks', r'// 21. - 25. August 2019'), + ('remarks', r'// Ziegeleipark Mildenberg, Zehdenick, Germany, Earth, Milky Way'), + ('remarks', r'// Join us: https://bgp.wtf/cccamp19'), + ('remarks', r''), +] + + +class IXPAutNum(IRRObject): + """An aut-num (AS) object.""" + TYPE = 'aut-num' + @classmethod + def make_for_members(cls, members): + fields = [ + ('aut-num', 'AS208521'), + ('as-name', 'BGPWTF-CCCAMP19-IX'), + ] + banner + [ + ('remarks', '// Current members:'), + ] + + for member in sorted(list(members)): + fields.append(('import', 'from {} accept {}'.format(member, member))) + fields.append(('export', 'to {} announce AS-CCCAMP19-IX'.format(member))) + + fields += [ + ('remarks', ''), + ('remarks', '// Abuse: noc@hackerspace.pl'), + ('org', 'ORG-SH103-RIPE'), + ('admin-c', 'HACK2-RIPE'), + ('tech-c', 'HACK2-RIPE'), + ('status', 'ASSIGNED'), + ('mnt-by', 'RIPE-NCC-END-MNT'), + ('mnt-by', 'BGPWTF-AUTOMATION'), + ('mnt-by', 'pl-hs-1-mnt'), + ('source', 'RIPE'), + ] + + return cls(fields) + + +class ASSet(IRRObject): + """An as-set object.""" + TYPE = 'as-set' + @classmethod + def make_for_members(cls, members): + fields = [ + ('as-set', 'AS-CCCAMP19-IX'), + ('admin-c', 'HACK2-RIPE'), + ] + banner + [ + ('remarks', '// Current members:'), + ] + + for member in sorted(list(members)): + fields.append(('members', member)) + + fields += [ + ('remarks', ''), + ('remarks', '// Abuse: noc@hackerspace.pl'), + ('tech-c', 'HACK2-RIPE'), + ('mnt-by', 'BGPWTF-AUTOMATION'), + ('mnt-by', 'pl-hs-1-mnt'), + ('source', 'RIPE'), + ] + + return cls(fields) + + +def sync(want, got, password, force): + """Sync an object if there is a diff to its current state.""" + wantr = want.render().split('\n') + gotr = got.render().split('\n') + + d = list(difflib.unified_diff(gotr, wantr, fromfile='got', tofile='want')) + fields_diff = set() + for dd in d: + if dd.startswith('---'): + continue + if dd.startswith('+++'): + continue + if not dd.startswith('+') and not dd.startswith('-'): + continue + + field = dd[1:].split(':')[0] + fields_diff.add(field) + + # We ignore remarks field changes, because the RIPE API returns us them always + # mangled (with spaces missing in the ASCII art). + if list(fields_diff) == ['remarks'] and not force: + print('No changes.') + return + + if force: + print('Forcing update') + else: + print('Diff:') + print('\n'.join(d)) + want.send_to_ripe(password) + print('Updated.') + + +def sync_autnum(members, password, force=False): + print('Syncing aut-num...') + want = IXPAutNum.make_for_members(members) + got = IXPAutNum.from_ripe('AS208521') + sync(want, got, password, force) + + + +def sync_asset(members, password, force=False): + print('Syncing as-set...') + want = ASSet.make_for_members(members) + got = ASSet.from_ripe('AS-CCCAMP19-IX') + sync(want, got, password, force) + + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print("Usage: {} password AS1,AS2,AS3,...".format(sys.argv[0])) + sys.exit(1) + + password = sys.argv[1] + members = [m.strip().upper() for m in sys.argv[2].split(',')] + + for member in members: + if not member.startswith('AS'): + raise Exception('{} is not a valid ASN'.format(member)) + + if not all(c in string.digits for c in member[2:]): + raise Exception('{} is not a valid ASN'.format(member)) + + sync_autnum(members, password) + sync_asset(members, password)