blob: 31c2d2edc032fbd41972bc6652ba8c2bbc5a93fb [file] [log] [blame]
Sergiusz Bazanski57be3f72019-07-19 20:58:50 +02001"""
2Updates IRR objects in RIPE for the CCCamp IX.
3
4This, given a mntner password and a list of ASNs taking part in the IXP will
5ensure the right IRR objects are present:
6
7 - an aut-num containing import/export RPSL rules
8 - an as-set containing all member ASs.
9
10
11"""
12
13import difflib
14import string
15import sys
16import time
17
18import requests
19
20
21class IRRObject:
22 """An IRR object from RIPE."""
23 TYPE = None
24 def __init__(self, fields=None):
25 self.fields = fields or []
26
27 def add(self, k, v):
28 self.fields.append((k, v))
29
30 def render(self):
31 """Render to IRR format."""
32 return '\n'.join('{:16s}{}'.format(k+":", v) for k, v in self.fields) + "\n"
33
34 @classmethod
35 def from_ripe(cls, v):
36 """Download this object from the RIPE REST API."""
37 if cls.TYPE is None:
38 raise Exception('cannot fetch untyped IRRObject')
39
40 r = requests.get('https://rest.db.ripe.net/ripe/{}/{}.json'.format(cls.TYPE, v))
41 d = r.json()
42
43 obj = d['objects']['object'][0]
44 assert obj['type'] == cls.TYPE
45 assert obj['primary-key']['attribute'][0]['value'] == v
46
47 attrs = obj['attributes']['attribute']
48
49 fields = []
50 for attr in attrs:
51 # Skip metadata.
52 if attr['name'] in ('created', 'last-modified'):
53 continue
54 fields.append((attr['name'], attr['value']))
55
56 return cls(fields)
57
58 def send_to_ripe(self, password):
59 """Update this object (or create new) in RIPE using the SyncUpdates API."""
60 res = self.render()
61 res += 'password: {}\n'.format(password)
62
63 data = {
64 'DATA': res,
65 }
66 r = requests.post('http://syncupdates.db.ripe.net/', files=data)
67 res = r.text
68 if 'Modify SUCCEEDED' not in res:
69 print(res)
70 raise Exception("Unexpected result from RIPE syncupdates")
71
72
73banner = [
74 ('remarks', r".--------------------------------------."),
75 ('remarks', r"| _ _ __ |"),
76 ('remarks', r"| | |__ __ _ _ ____ _| |_ / _| |"),
77 ('remarks', r"| | '_ \ / _` | '_ \ \ /\ / / __| |_ |"),
78 ('remarks', r"| | |_) | (_| | |_) \ V V /| |_| _| |"),
79 ('remarks', r"| |_.__/ \__, | .__(_)_/\_/ \__|_| |"),
80 ('remarks', r"| |___/|_| |"),
81 ('remarks', r"|--------------------------------------|"),
82 ('remarks', r"| CCCamp2019 Internet Exchange Point |"),
83 ('remarks', r"'--------------------------------------'"),
84 ('remarks', r''),
85 ('remarks', r'// 21. - 25. August 2019'),
86 ('remarks', r'// Ziegeleipark Mildenberg, Zehdenick, Germany, Earth, Milky Way'),
87 ('remarks', r'// Join us: https://bgp.wtf/cccamp19'),
88 ('remarks', r''),
89]
90
91
92class IXPAutNum(IRRObject):
93 """An aut-num (AS) object."""
94 TYPE = 'aut-num'
95 @classmethod
96 def make_for_members(cls, members):
97 fields = [
98 ('aut-num', 'AS208521'),
99 ('as-name', 'BGPWTF-CCCAMP19-IX'),
100 ] + banner + [
101 ('remarks', '// Current members:'),
102 ]
103
104 for member in sorted(list(members)):
105 fields.append(('import', 'from {} accept {}'.format(member, member)))
106 fields.append(('export', 'to {} announce AS-CCCAMP19-IX'.format(member)))
107
108 fields += [
109 ('remarks', ''),
110 ('remarks', '// Abuse: noc@hackerspace.pl'),
111 ('org', 'ORG-SH103-RIPE'),
112 ('admin-c', 'HACK2-RIPE'),
113 ('tech-c', 'HACK2-RIPE'),
114 ('status', 'ASSIGNED'),
115 ('mnt-by', 'RIPE-NCC-END-MNT'),
116 ('mnt-by', 'BGPWTF-AUTOMATION'),
117 ('mnt-by', 'pl-hs-1-mnt'),
118 ('source', 'RIPE'),
119 ]
120
121 return cls(fields)
122
123
124class ASSet(IRRObject):
125 """An as-set object."""
126 TYPE = 'as-set'
127 @classmethod
128 def make_for_members(cls, members):
129 fields = [
130 ('as-set', 'AS-CCCAMP19-IX'),
131 ('admin-c', 'HACK2-RIPE'),
132 ] + banner + [
133 ('remarks', '// Current members:'),
134 ]
135
136 for member in sorted(list(members)):
137 fields.append(('members', member))
138
139 fields += [
140 ('remarks', ''),
141 ('remarks', '// Abuse: noc@hackerspace.pl'),
142 ('tech-c', 'HACK2-RIPE'),
143 ('mnt-by', 'BGPWTF-AUTOMATION'),
144 ('mnt-by', 'pl-hs-1-mnt'),
145 ('source', 'RIPE'),
146 ]
147
148 return cls(fields)
149
150
151def sync(want, got, password, force):
152 """Sync an object if there is a diff to its current state."""
153 wantr = want.render().split('\n')
154 gotr = got.render().split('\n')
155
156 d = list(difflib.unified_diff(gotr, wantr, fromfile='got', tofile='want'))
157 fields_diff = set()
158 for dd in d:
159 if dd.startswith('---'):
160 continue
161 if dd.startswith('+++'):
162 continue
163 if not dd.startswith('+') and not dd.startswith('-'):
164 continue
165
166 field = dd[1:].split(':')[0]
167 fields_diff.add(field)
168
169 # We ignore remarks field changes, because the RIPE API returns us them always
170 # mangled (with spaces missing in the ASCII art).
171 if list(fields_diff) == ['remarks'] and not force:
172 print('No changes.')
173 return
174
175 if force:
176 print('Forcing update')
177 else:
178 print('Diff:')
179 print('\n'.join(d))
180 want.send_to_ripe(password)
181 print('Updated.')
182
183
184def sync_autnum(members, password, force=False):
185 print('Syncing aut-num...')
186 want = IXPAutNum.make_for_members(members)
187 got = IXPAutNum.from_ripe('AS208521')
188 sync(want, got, password, force)
189
190
191
192def sync_asset(members, password, force=False):
193 print('Syncing as-set...')
194 want = ASSet.make_for_members(members)
195 got = ASSet.from_ripe('AS-CCCAMP19-IX')
196 sync(want, got, password, force)
197
198
199
200if __name__ == '__main__':
201 if len(sys.argv) != 3:
202 print("Usage: {} password AS1,AS2,AS3,...".format(sys.argv[0]))
203 sys.exit(1)
204
205 password = sys.argv[1]
206 members = [m.strip().upper() for m in sys.argv[2].split(',')]
207
208 for member in members:
209 if not member.startswith('AS'):
210 raise Exception('{} is not a valid ASN'.format(member))
211
212 if not all(c in string.digits for c in member[2:]):
213 raise Exception('{} is not a valid ASN'.format(member))
214
215 sync_autnum(members, password)
216 sync_asset(members, password)