Commit | Line | Data |
---|---|---|
3454dce4 SE |
1 | #! /usr/bin/env python |
2 | # Copyright (C) 2001 Stephen Early <steve@greenend.org.uk> | |
3 | # | |
4 | # This program is free software; you can redistribute it and/or modify | |
5 | # it under the terms of the GNU General Public License as published by | |
6 | # the Free Software Foundation; either version 2 of the License, or | |
7 | # (at your option) any later version. | |
8 | # | |
9 | # This program is distributed in the hope that it will be useful, | |
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | # GNU General Public License for more details. | |
13 | # | |
14 | # You should have received a copy of the GNU General Public License | |
15 | # along with this program; if not, write to the Free Software | |
16 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA | |
17 | ||
18 | """VPN sites file manipulation. | |
19 | ||
20 | This program enables VPN site descriptions to be submitted for | |
21 | inclusion in a central database, and allows the resulting database to | |
22 | be turned into a secnet configuration file. | |
23 | ||
24 | A database file can be turned into a secnet configuration file simply: | |
25 | make-secnet-sites.py [infile [outfile]] | |
26 | ||
27 | It would be wise to run secnet with the "--just-check-config" option | |
28 | before installing the output on a live system. | |
29 | ||
30 | The program expects to be invoked via userv to manage the database; it | |
31 | relies on the USERV_USER and USERV_GROUP environment variables. The | |
32 | command line arguments for this invocation are: | |
33 | ||
34 | make-secnet-sites.py -u header-filename groupfiles-directory output-file \ | |
35 | group | |
36 | ||
37 | All but the last argument are expected to be set by userv; the 'group' | |
38 | argument is provided by the user. A suitable userv configuration file | |
39 | fragment is: | |
40 | ||
41 | reset | |
42 | no-disconnect-hup | |
43 | no-suppress-args | |
44 | cd ~/secnet/sites-test/ | |
08f344d3 | 45 | execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites |
3454dce4 SE |
46 | |
47 | This program is part of secnet. It relies on the "ipaddr" library from | |
48 | Cendio Systems AB. | |
49 | ||
50 | """ | |
51 | ||
52 | import string | |
53 | import time | |
54 | import sys | |
55 | import os | |
8dea8d37 SE |
56 | |
57 | sys.path.append("/usr/local/share/secnet") | |
58 | sys.path.append("/usr/share/secnet") | |
3454dce4 SE |
59 | import ipaddr |
60 | ||
ff05a229 | 61 | VERSION="0.1.13" |
3454dce4 SE |
62 | |
63 | class vpn: | |
64 | def __init__(self,name): | |
65 | self.name=name | |
66 | self.allow_defs=0 | |
67 | self.locations={} | |
68 | self.defs={} | |
69 | ||
70 | class location: | |
71 | def __init__(self,name,vpn): | |
72 | self.group=None | |
73 | self.name=name | |
74 | self.allow_defs=1 | |
75 | self.vpn=vpn | |
76 | self.sites={} | |
77 | self.defs={} | |
78 | ||
79 | class site: | |
80 | def __init__(self,name,location): | |
81 | self.name=name | |
82 | self.allow_defs=1 | |
83 | self.location=location | |
84 | self.defs={} | |
85 | ||
86 | class nets: | |
87 | def __init__(self,w): | |
88 | self.w=w | |
89 | self.set=ipaddr.ip_set() | |
90 | for i in w[1:]: | |
91 | x=string.split(i,"/") | |
92 | self.set.append(ipaddr.network(x[0],x[1], | |
93 | ipaddr.DEMAND_NETWORK)) | |
94 | def subsetof(self,s): | |
95 | # I'd like to do this: | |
96 | # return self.set.is_subset(s) | |
97 | # but there isn't an is_subset() method | |
98 | # Instead we see if we intersect with the complement of s | |
99 | sc=s.set.complement() | |
100 | i=sc.intersection(self.set) | |
101 | return i.is_empty() | |
102 | def out(self): | |
469fd1d9 SE |
103 | if (self.w[0]=='restrict-nets'): pattern="# restrict-nets %s;" |
104 | else: | |
105 | pattern="link netlink { routes %s; };" | |
106 | return pattern%string.join(map(lambda x:'"%s/%s"'%(x.ip_str(), | |
3454dce4 | 107 | x.mask.netmask_bits_str), |
469fd1d9 | 108 | self.set.as_list_of_networks()),",") |
3454dce4 SE |
109 | |
110 | class dhgroup: | |
111 | def __init__(self,w): | |
b2a56f7c SE |
112 | self.mod=w[1] |
113 | self.gen=w[2] | |
3454dce4 | 114 | def out(self): |
b2a56f7c | 115 | return 'dh diffie-hellman("%s","%s");'%(self.mod,self.gen) |
3454dce4 SE |
116 | |
117 | class hash: | |
118 | def __init__(self,w): | |
b2a56f7c SE |
119 | self.ht=w[1] |
120 | if (self.ht!='md5' and self.ht!='sha1'): | |
121 | complain("unknown hash type %s"%(self.ht)) | |
3454dce4 | 122 | def out(self): |
b2a56f7c | 123 | return 'hash %s;'%(self.ht) |
3454dce4 SE |
124 | |
125 | class email: | |
126 | def __init__(self,w): | |
b2a56f7c | 127 | self.addr=w[1] |
3454dce4 | 128 | def out(self): |
b2a56f7c | 129 | return '# Contact email address: <%s>'%(self.addr) |
3454dce4 SE |
130 | |
131 | class num: | |
132 | def __init__(self,w): | |
b2a56f7c SE |
133 | self.what=w[0] |
134 | self.n=string.atol(w[1]) | |
3454dce4 | 135 | def out(self): |
b2a56f7c | 136 | return '%s %d;'%(self.what,self.n) |
3454dce4 SE |
137 | |
138 | class address: | |
139 | def __init__(self,w): | |
140 | self.w=w | |
b2a56f7c SE |
141 | self.adr=w[1] |
142 | self.port=string.atoi(w[2]) | |
143 | if (self.port<1 or self.port>65535): | |
144 | complain("invalid port number") | |
3454dce4 | 145 | def out(self): |
b2a56f7c | 146 | return 'address "%s"; port %d;'%(self.adr,self.port) |
3454dce4 SE |
147 | |
148 | class rsakey: | |
149 | def __init__(self,w): | |
b2a56f7c SE |
150 | self.l=string.atoi(w[1]) |
151 | self.e=w[2] | |
152 | self.n=w[3] | |
3454dce4 | 153 | def out(self): |
b2a56f7c | 154 | return 'key rsa-public("%s","%s");'%(self.e,self.n) |
3454dce4 SE |
155 | |
156 | class mobileoption: | |
157 | def __init__(self,w): | |
158 | self.w=w | |
159 | def out(self): | |
08f344d3 | 160 | return '# netlink-options "soft";' |
3454dce4 SE |
161 | |
162 | def complain(msg): | |
163 | global complaints | |
164 | print ("%s line %d: "%(file,line))+msg | |
165 | complaints=complaints+1 | |
166 | def moan(msg): | |
167 | global complaints | |
168 | print msg; | |
169 | complaints=complaints+1 | |
170 | ||
171 | # We don't allow redefinition of properties (because that would allow things | |
172 | # like restrict-nets to be redefined, which would be bad) | |
173 | def set(obj,defs,w): | |
174 | if (obj.allow_defs | allow_defs): | |
175 | if (obj.defs.has_key(w[0])): | |
176 | complain("%s is already defined"%(w[0])) | |
177 | else: | |
178 | t=defs[w[0]] | |
179 | obj.defs[w[0]]=t(w) | |
180 | ||
181 | # Process a line of configuration file | |
182 | def pline(i): | |
183 | global allow_defs, group, current_vpn, current_location, current_object | |
184 | w=string.split(i) | |
185 | if len(w)==0: return | |
186 | keyword=w[0] | |
187 | if keyword=='end-definitions': | |
188 | allow_defs=0 | |
189 | current_vpn=None | |
190 | current_location=None | |
191 | current_object=None | |
192 | return | |
193 | if keyword=='vpn': | |
194 | if vpns.has_key(w[1]): | |
195 | current_vpn=vpns[w[1]] | |
196 | current_object=current_vpn | |
197 | else: | |
198 | if allow_defs: | |
199 | current_vpn=vpn(w[1]) | |
200 | vpns[w[1]]=current_vpn | |
201 | current_object=current_vpn | |
202 | else: | |
203 | complain("no new VPN definitions allowed") | |
204 | return | |
205 | if (current_vpn==None): | |
206 | complain("no VPN defined yet") | |
207 | return | |
208 | # Keywords that can apply at all levels | |
209 | if mldefs.has_key(w[0]): | |
210 | set(current_object,mldefs,w) | |
211 | return | |
212 | if keyword=='location': | |
213 | if (current_vpn.locations.has_key(w[1])): | |
214 | current_location=current_vpn.locations[w[1]] | |
215 | current_object=current_location | |
216 | if (group and not allow_defs and | |
217 | current_location.group!=group): | |
218 | complain(("must be group %s to access "+ | |
219 | "location %s")%(current_location.group, | |
220 | w[1])) | |
221 | else: | |
222 | if allow_defs: | |
223 | if reserved.has_key(w[1]): | |
224 | complain("reserved location name") | |
225 | return | |
226 | current_location=location(w[1],current_vpn) | |
227 | current_vpn.locations[w[1]]=current_location | |
228 | current_object=current_location | |
229 | else: | |
230 | complain("no new location definitions allowed") | |
231 | return | |
232 | if (current_location==None): | |
233 | complain("no locations defined yet") | |
234 | return | |
235 | if keyword=='group': | |
236 | current_location.group=w[1] | |
237 | return | |
238 | if keyword=='site': | |
239 | if (current_location.sites.has_key(w[1])): | |
240 | current_object=current_location.sites[w[1]] | |
241 | else: | |
242 | if reserved.has_key(w[1]): | |
243 | complain("reserved site name") | |
244 | return | |
245 | current_object=site(w[1],current_location) | |
246 | current_location.sites[w[1]]=current_object | |
247 | return | |
248 | if keyword=='endsite': | |
249 | if isinstance(current_object,site): | |
250 | current_object=current_object.location | |
251 | else: | |
252 | complain("not currently defining a site") | |
253 | return | |
254 | # Keywords that can only apply to sites | |
255 | if isinstance(current_object,site): | |
256 | if sitedefs.has_key(w[0]): | |
257 | set(current_object,sitedefs,w) | |
258 | return | |
259 | else: | |
260 | if sitedefs.has_key(w[0]): | |
261 | complain("keyword '%s' can only be used in the " | |
262 | "context of a site definition"%(w[0])) | |
263 | return | |
264 | complain("unknown keyword '%s'"%(w[0])) | |
265 | ||
266 | def pfile(name,lines): | |
267 | global file,line | |
268 | file=name | |
269 | line=0 | |
270 | for i in lines: | |
271 | line=line+1 | |
272 | if (i[0]=='#'): continue | |
273 | if (i[len(i)-1]=='\n'): i=i[:len(i)-1] # strip trailing LF | |
274 | pline(i) | |
275 | ||
276 | def outputsites(w): | |
277 | w.write("# secnet sites file autogenerated by make-secnet-sites.py " | |
278 | +"version %s\n"%VERSION) | |
279 | w.write("# %s\n\n"%time.asctime(time.localtime(time.time()))) | |
280 | ||
281 | # Raw VPN data section of file | |
282 | w.write("vpn-data {\n") | |
283 | for i in vpns.values(): | |
284 | w.write(" %s {\n"%i.name) | |
285 | for d in i.defs.values(): | |
286 | w.write(" %s\n"%d.out()) | |
287 | w.write("\n") | |
288 | for l in i.locations.values(): | |
289 | w.write(" %s {\n"%l.name) | |
290 | for d in l.defs.values(): | |
291 | w.write(" %s\n"%d.out()) | |
292 | for s in l.sites.values(): | |
293 | w.write(" %s {\n"%s.name) | |
294 | w.write(' name "%s/%s/%s";\n'% | |
295 | (i.name,l.name,s.name)) | |
296 | for d in s.defs.values(): | |
297 | w.write(" %s\n"%d.out()) | |
298 | w.write(" };\n") | |
299 | w.write(" };\n") | |
300 | w.write(" };\n") | |
301 | w.write("};\n") | |
302 | ||
303 | # Per-VPN flattened lists | |
304 | w.write("vpn {\n") | |
305 | for i in vpns.values(): | |
306 | w.write(" %s {\n"%(i.name)) | |
307 | for l in i.locations.values(): | |
b2a56f7c SE |
308 | tmpl="vpn-data/%s/%s/%%s"%(i.name,l.name) |
309 | slist=[] | |
310 | for s in l.sites.values(): slist.append(tmpl%s.name) | |
3454dce4 SE |
311 | w.write(" %s %s;\n"%(l.name,string.join(slist,","))) |
312 | w.write("\n all-sites %s;\n"% | |
313 | string.join(i.locations.keys(),",")) | |
314 | w.write(" };\n") | |
315 | w.write("};\n") | |
316 | ||
317 | # Flattened list of sites | |
318 | w.write("all-sites %s;\n"%string.join(map(lambda x:"vpn/%s/all-sites"% | |
319 | x,vpns.keys()),",")) | |
320 | ||
321 | # Are we being invoked from userv? | |
322 | service=0 | |
323 | # If we are, which group does the caller want to modify? | |
324 | group=None | |
325 | ||
326 | vpns={} | |
327 | allow_defs=1 | |
328 | current_vpn=None | |
329 | current_location=None | |
330 | current_object=None | |
331 | ||
332 | line=0 | |
333 | file=None | |
334 | complaints=0 | |
335 | ||
336 | # Things that can be defined at any level | |
337 | mldefs={ | |
338 | 'dh':dhgroup, | |
339 | 'hash':hash, | |
340 | 'contact':email, | |
341 | 'key-lifetime':num, | |
342 | 'setup-retries':num, | |
343 | 'setup-timeout':num, | |
344 | 'wait-time':num, | |
345 | 'renegotiate-time':num, | |
346 | 'restrict-nets':nets | |
347 | } | |
348 | ||
349 | # Things that can only be defined for sites | |
350 | sitedefs={ | |
351 | 'address':address, | |
352 | 'networks':nets, | |
353 | 'pubkey':rsakey, | |
354 | 'mobile':mobileoption | |
355 | } | |
356 | ||
357 | # Reserved vpn/location/site names | |
358 | reserved={'all-sites':None} | |
359 | reserved.update(mldefs) | |
360 | reserved.update(sitedefs) | |
361 | ||
362 | # Each site must have the following defined at some level: | |
363 | required={ | |
364 | 'dh':"Diffie-Hellman group", | |
365 | 'networks':"network list", | |
366 | 'pubkey':"public key", | |
367 | 'hash':"hash function" | |
368 | } | |
369 | ||
370 | if len(sys.argv)<2: | |
371 | pfile("stdin",sys.stdin.readlines()) | |
372 | of=sys.stdout | |
373 | else: | |
374 | if sys.argv[1]=='-u': | |
375 | if len(sys.argv)!=6: | |
376 | print "Wrong number of arguments" | |
377 | sys.exit(1) | |
378 | service=1 | |
379 | header=sys.argv[2] | |
380 | groupfiledir=sys.argv[3] | |
381 | sitesfile=sys.argv[4] | |
382 | group=sys.argv[5] | |
383 | if not os.environ.has_key("USERV_USER"): | |
384 | print "Environment variable USERV_USER not found" | |
385 | sys.exit(1) | |
386 | user=os.environ["USERV_USER"] | |
387 | # Check that group is in USERV_GROUP | |
388 | if not os.environ.has_key("USERV_GROUP"): | |
389 | print "Environment variable USERV_GROUP not found" | |
390 | sys.exit(1) | |
391 | ugs=os.environ["USERV_GROUP"] | |
392 | ok=0 | |
393 | for i in string.split(ugs): | |
394 | if group==i: ok=1 | |
395 | if not ok: | |
396 | print "caller not in group %s"%group | |
397 | sys.exit(1) | |
398 | f=open(header) | |
08f344d3 | 399 | headerinput=f.readlines() |
3454dce4 | 400 | f.close() |
08f344d3 | 401 | pfile(header,headerinput) |
3454dce4 SE |
402 | userinput=sys.stdin.readlines() |
403 | pfile("user input",userinput) | |
404 | else: | |
405 | if len(sys.argv)>3: | |
406 | print "Too many arguments" | |
407 | sys.exit(1) | |
408 | f=open(sys.argv[1]) | |
409 | pfile(sys.argv[1],f.readlines()) | |
410 | f.close() | |
411 | of=sys.stdout | |
412 | if len(sys.argv)>2: | |
413 | of=open(sys.argv[2],'w') | |
414 | ||
415 | # Sanity check section | |
416 | ||
417 | # Delete locations that have no sites defined | |
418 | for i in vpns.values(): | |
419 | for l in i.locations.keys(): | |
420 | if (len(i.locations[l].sites.values())==0): | |
421 | del i.locations[l] | |
422 | ||
423 | # Delete VPNs that have no locations with sites defined | |
424 | for i in vpns.keys(): | |
425 | if (len(vpns[i].locations.values())==0): | |
426 | del vpns[i] | |
427 | ||
428 | # Check all sites | |
429 | for i in vpns.values(): | |
430 | if i.defs.has_key('restrict-nets'): | |
431 | vr=i.defs['restrict-nets'] | |
432 | else: | |
433 | vr=None | |
434 | for l in i.locations.values(): | |
435 | if l.defs.has_key('restrict-nets'): | |
436 | lr=l.defs['restrict-nets'] | |
437 | if (not lr.subsetof(vr)): | |
438 | moan("location %s/%s restrict-nets is invalid"% | |
439 | (i.name,l.name)) | |
440 | else: | |
441 | lr=vr | |
442 | for s in l.sites.values(): | |
443 | sn="%s/%s/%s"%(i.name,l.name,s.name) | |
444 | for r in required.keys(): | |
445 | if (not (s.defs.has_key(r) or | |
446 | l.defs.has_key(r) or | |
447 | i.defs.has_key(r))): | |
448 | moan("site %s missing parameter %s"% | |
449 | (sn,r)) | |
450 | if s.defs.has_key('restrict-nets'): | |
451 | sr=s.defs['restrict-nets'] | |
452 | if (not sr.subsetof(lr)): | |
453 | moan("site %s restrict-nets not valid"% | |
454 | sn) | |
455 | else: | |
456 | sr=lr | |
457 | if not s.defs.has_key('networks'): continue | |
458 | nets=s.defs['networks'] | |
459 | if (not nets.subsetof(sr)): | |
460 | moan("site %s networks exceed restriction"%sn) | |
461 | ||
462 | ||
463 | if complaints>0: | |
464 | if complaints==1: print "There was 1 problem." | |
465 | else: print "There were %d problems."%(complaints) | |
466 | sys.exit(1) | |
467 | ||
468 | if service: | |
469 | # Put the user's input into their group file, and rebuild the main | |
470 | # sites file | |
08f344d3 | 471 | f=open(groupfiledir+"/T"+group,'w') |
3454dce4 SE |
472 | f.write("# Section submitted by user %s, %s\n"% |
473 | (user,time.asctime(time.localtime(time.time())))) | |
474 | f.write("# Checked by make-secnet-sites.py version %s\n\n"%VERSION) | |
475 | for i in userinput: f.write(i) | |
476 | f.write("\n") | |
477 | f.close() | |
08f344d3 SE |
478 | os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group) |
479 | f=open(sitesfile+"-tmp",'w') | |
ff05a229 | 480 | f.write("# sites file autogenerated by make-secnet-sites\n") |
08f344d3 SE |
481 | f.write("# generated %s, invoked by %s\n"% |
482 | (time.asctime(time.localtime(time.time())),user)) | |
ff05a229 | 483 | f.write("# use make-secnet-sites to turn this file into a\n") |
08f344d3 SE |
484 | f.write("# valid /etc/secnet/sites.conf file\n\n") |
485 | for i in headerinput: f.write(i) | |
486 | files=os.listdir(groupfiledir) | |
487 | for i in files: | |
488 | if i[0]=='R': | |
489 | j=open(groupfiledir+"/"+i) | |
490 | f.write(j.read()) | |
491 | j.close() | |
492 | f.write("# end of sites file\n") | |
493 | f.close() | |
494 | os.rename(sitesfile+"-tmp",sitesfile) | |
3454dce4 SE |
495 | else: |
496 | outputsites(of) |