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/BUILD b/bgpwtf/cccampix/BUILD
new file mode 100644
index 0000000..0d9fd06
--- /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 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)