Import release 0.1.13
[secnet] / make-secnet-sites
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/
45 execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
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
56
57 sys.path.append("/usr/local/share/secnet")
58 sys.path.append("/usr/share/secnet")
59 import ipaddr
60
61 VERSION="0.1.13"
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):
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(),
107 x.mask.netmask_bits_str),
108 self.set.as_list_of_networks()),",")
109
110 class dhgroup:
111 def __init__(self,w):
112 self.mod=w[1]
113 self.gen=w[2]
114 def out(self):
115 return 'dh diffie-hellman("%s","%s");'%(self.mod,self.gen)
116
117 class hash:
118 def __init__(self,w):
119 self.ht=w[1]
120 if (self.ht!='md5' and self.ht!='sha1'):
121 complain("unknown hash type %s"%(self.ht))
122 def out(self):
123 return 'hash %s;'%(self.ht)
124
125 class email:
126 def __init__(self,w):
127 self.addr=w[1]
128 def out(self):
129 return '# Contact email address: <%s>'%(self.addr)
130
131 class num:
132 def __init__(self,w):
133 self.what=w[0]
134 self.n=string.atol(w[1])
135 def out(self):
136 return '%s %d;'%(self.what,self.n)
137
138 class address:
139 def __init__(self,w):
140 self.w=w
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")
145 def out(self):
146 return 'address "%s"; port %d;'%(self.adr,self.port)
147
148 class rsakey:
149 def __init__(self,w):
150 self.l=string.atoi(w[1])
151 self.e=w[2]
152 self.n=w[3]
153 def out(self):
154 return 'key rsa-public("%s","%s");'%(self.e,self.n)
155
156 class mobileoption:
157 def __init__(self,w):
158 self.w=w
159 def out(self):
160 return '# netlink-options "soft";'
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():
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)
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)
399 headerinput=f.readlines()
400 f.close()
401 pfile(header,headerinput)
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
471 f=open(groupfiledir+"/T"+group,'w')
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()
478 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
479 f=open(sitesfile+"-tmp",'w')
480 f.write("# sites file autogenerated by make-secnet-sites\n")
481 f.write("# generated %s, invoked by %s\n"%
482 (time.asctime(time.localtime(time.time())),user))
483 f.write("# use make-secnet-sites to turn this file into a\n")
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)
495 else:
496 outputsites(of)