bgpwtf/cccampix: init
Add sync script for camp IX.
This will likely be triggered externally from some sort of long-running
service.
Change-Id: I4ead566e4308d24fdb64e789a7ca0e3dbf0214fb
diff --git a/bgpwtf/cccampix/sync.py b/bgpwtf/cccampix/sync.py
new file mode 100644
index 0000000..31c2d2e
--- /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)