polypath: Log pid of long-running interface monitor
[secnet] / make-secnet-sites
1 #! /usr/bin/env python
2 # Copyright (C) 2001-2002 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 import getopt
57 import re
58
59 import ipaddr
60
61 sys.path.insert(0,"/usr/local/share/secnet")
62 sys.path.insert(0,"/usr/share/secnet")
63 import ipaddrset
64
65 VERSION="0.1.18"
66
67 # Classes describing possible datatypes in the configuration file
68
69 class single_ipaddr:
70 "An IP address"
71 def __init__(self,w):
72 self.addr=ipaddr.IPAddress(w[1])
73 def __str__(self):
74 return '"%s"'%self.addr
75
76 class networks:
77 "A set of IP addresses specified as a list of networks"
78 def __init__(self,w):
79 self.set=ipaddrset.IPAddressSet()
80 for i in w[1:]:
81 x=ipaddr.IPNetwork(i,strict=True)
82 self.set.append([x])
83 def __str__(self):
84 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
85
86 class dhgroup:
87 "A Diffie-Hellman group"
88 def __init__(self,w):
89 self.mod=w[1]
90 self.gen=w[2]
91 def __str__(self):
92 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
93
94 class hash:
95 "A choice of hash function"
96 def __init__(self,w):
97 self.ht=w[1]
98 if (self.ht!='md5' and self.ht!='sha1'):
99 complain("unknown hash type %s"%(self.ht))
100 def __str__(self):
101 return '%s'%(self.ht)
102
103 class email:
104 "An email address"
105 def __init__(self,w):
106 self.addr=w[1]
107 def __str__(self):
108 return '<%s>'%(self.addr)
109
110 class boolean:
111 "A boolean"
112 def __init__(self,w):
113 if re.match('[TtYy1]',w[1]):
114 self.b=True
115 elif re.match('[FfNn0]',w[1]):
116 self.b=False
117 else:
118 complain("invalid boolean value");
119 def __str__(self):
120 return ['False','True'][self.b]
121
122 class num:
123 "A decimal number"
124 def __init__(self,w):
125 self.n=string.atol(w[1])
126 def __str__(self):
127 return '%d'%(self.n)
128
129 class address:
130 "A DNS name and UDP port number"
131 def __init__(self,w):
132 self.adr=w[1]
133 self.port=string.atoi(w[2])
134 if (self.port<1 or self.port>65535):
135 complain("invalid port number")
136 def __str__(self):
137 return '"%s"; port %d'%(self.adr,self.port)
138
139 class rsakey:
140 "An RSA public key"
141 def __init__(self,w):
142 self.l=string.atoi(w[1])
143 self.e=w[2]
144 self.n=w[3]
145 def __str__(self):
146 return 'rsa-public("%s","%s")'%(self.e,self.n)
147
148 # Possible properties of configuration nodes
149 keywords={
150 'contact':(email,"Contact address"),
151 'dh':(dhgroup,"Diffie-Hellman group"),
152 'hash':(hash,"Hash function"),
153 'key-lifetime':(num,"Maximum key lifetime (ms)"),
154 'setup-timeout':(num,"Key setup timeout (ms)"),
155 'setup-retries':(num,"Maximum key setup packet retries"),
156 'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
157 'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
158 'restrict-nets':(networks,"Allowable networks"),
159 'networks':(networks,"Claimed networks"),
160 'pubkey':(rsakey,"RSA public site key"),
161 'peer':(single_ipaddr,"Tunnel peer IP address"),
162 'address':(address,"External contact address and port"),
163 'mobile':(boolean,"Site is mobile"),
164 }
165
166 def sp(name,value):
167 "Simply output a property - the default case"
168 return "%s %s;\n"%(name,value)
169
170 # All levels support these properties
171 global_properties={
172 'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
173 'dh':sp,
174 'hash':sp,
175 'key-lifetime':sp,
176 'setup-timeout':sp,
177 'setup-retries':sp,
178 'wait-time':sp,
179 'renegotiate-time':sp,
180 'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
181 }
182
183 class level:
184 "A level in the configuration hierarchy"
185 depth=0
186 leaf=0
187 allow_properties={}
188 require_properties={}
189 def __init__(self,w):
190 self.name=w[1]
191 self.properties={}
192 self.children={}
193 def indent(self,w,t):
194 w.write(" "[:t])
195 def prop_out(self,n):
196 return self.allow_properties[n](n,str(self.properties[n]))
197 def output_props(self,w,ind):
198 for i in self.properties.keys():
199 if self.allow_properties[i]:
200 self.indent(w,ind)
201 w.write("%s"%self.prop_out(i))
202 def output_data(self,w,ind,np):
203 self.indent(w,ind)
204 w.write("%s {\n"%(self.name))
205 self.output_props(w,ind+2)
206 if self.depth==1: w.write("\n");
207 for c in self.children.values():
208 c.output_data(w,ind+2,np+self.name+"/")
209 self.indent(w,ind)
210 w.write("};\n")
211
212 class vpnlevel(level):
213 "VPN level in the configuration hierarchy"
214 depth=1
215 leaf=0
216 type="vpn"
217 allow_properties=global_properties.copy()
218 require_properties={
219 'contact':"VPN admin contact address"
220 }
221 def __init__(self,w):
222 level.__init__(self,w)
223 def output_vpnflat(self,w,ind,h):
224 "Output flattened list of site names for this VPN"
225 self.indent(w,ind)
226 w.write("%s {\n"%(self.name))
227 for i in self.children.keys():
228 self.children[i].output_vpnflat(w,ind+2,
229 h+"/"+self.name+"/"+i)
230 w.write("\n")
231 self.indent(w,ind+2)
232 w.write("all-sites %s;\n"%
233 string.join(self.children.keys(),','))
234 self.indent(w,ind)
235 w.write("};\n")
236
237 class locationlevel(level):
238 "Location level in the configuration hierarchy"
239 depth=2
240 leaf=0
241 type="location"
242 allow_properties=global_properties.copy()
243 require_properties={
244 'contact':"Location admin contact address",
245 }
246 def __init__(self,w):
247 level.__init__(self,w)
248 self.group=w[2]
249 def output_vpnflat(self,w,ind,h):
250 self.indent(w,ind)
251 # The "h=h,self=self" abomination below exists because
252 # Python didn't support nested_scopes until version 2.1
253 w.write("%s %s;\n"%(self.name,string.join(
254 map(lambda x,h=h,self=self:
255 h+"/"+x,self.children.keys()),',')))
256
257 class sitelevel(level):
258 "Site level (i.e. a leafnode) in the configuration hierarchy"
259 depth=3
260 leaf=1
261 type="site"
262 allow_properties=global_properties.copy()
263 allow_properties.update({
264 'address':sp,
265 'networks':None,
266 'peer':None,
267 'pubkey':(lambda n,v:"key %s;\n"%v),
268 'address':(lambda n,v:"address %s;\n"%v),
269 'mobile':sp,
270 })
271 require_properties={
272 'dh':"Diffie-Hellman group",
273 'contact':"Site admin contact address",
274 'networks':"Networks claimed by the site",
275 'hash':"hash function",
276 'peer':"Gateway address of the site",
277 'pubkey':"RSA public key of the site",
278 }
279 def __init__(self,w):
280 level.__init__(self,w)
281 def output_data(self,w,ind,np):
282 self.indent(w,ind)
283 w.write("%s {\n"%(self.name))
284 self.indent(w,ind+2)
285 w.write("name \"%s\";\n"%(np+self.name))
286 self.output_props(w,ind+2)
287 self.indent(w,ind+2)
288 w.write("link netlink {\n");
289 self.indent(w,ind+4)
290 w.write("routes %s;\n"%str(self.properties["networks"]))
291 self.indent(w,ind+4)
292 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
293 self.indent(w,ind+2)
294 w.write("};\n")
295 self.indent(w,ind)
296 w.write("};\n")
297
298 # Levels in the configuration file
299 # (depth,properties)
300 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
301
302 # Reserved vpn/location/site names
303 reserved={'all-sites':None}
304 reserved.update(keywords)
305 reserved.update(levels)
306
307 def complain(msg):
308 "Complain about a particular input line"
309 global complaints
310 print ("%s line %d: "%(file,line))+msg
311 complaints=complaints+1
312 def moan(msg):
313 "Complain about something in general"
314 global complaints
315 print msg;
316 complaints=complaints+1
317
318 root=level(['root','root']) # All vpns are children of this node
319 obstack=[root]
320 allow_defs=0 # Level above which new definitions are permitted
321 prefix=''
322
323 def set_property(obj,w):
324 "Set a property on a configuration node"
325 if obj.properties.has_key(w[0]):
326 complain("%s %s already has property %s defined"%
327 (obj.type,obj.name,w[0]))
328 else:
329 obj.properties[w[0]]=keywords[w[0]][0](w)
330
331 def pline(i,allow_include=False):
332 "Process a configuration file line"
333 global allow_defs, obstack, root
334 w=string.split(i.rstrip('\n'))
335 if len(w)==0: return [i]
336 keyword=w[0]
337 current=obstack[len(obstack)-1]
338 if keyword=='end-definitions':
339 allow_defs=sitelevel.depth
340 obstack=[root]
341 return [i]
342 if keyword=='include':
343 if not allow_include:
344 complain("include not permitted here")
345 return []
346 if len(w) != 2:
347 complain("include requires one argument")
348 return []
349 newfile=os.path.join(os.path.dirname(file),w[1])
350 return pfilepath(newfile,allow_include=allow_include)
351 if levels.has_key(keyword):
352 # We may go up any number of levels, but only down by one
353 newdepth=levels[keyword].depth
354 currentdepth=len(obstack) # actually +1...
355 if newdepth<=currentdepth:
356 obstack=obstack[:newdepth]
357 if newdepth>currentdepth:
358 complain("May not go from level %d to level %d"%
359 (currentdepth-1,newdepth))
360 # See if it's a new one (and whether that's permitted)
361 # or an existing one
362 current=obstack[len(obstack)-1]
363 if current.children.has_key(w[1]):
364 # Not new
365 current=current.children[w[1]]
366 if service and group and current.depth==2:
367 if group!=current.group:
368 complain("Incorrect group!")
369 else:
370 # New
371 # Ignore depth check for now
372 nl=levels[keyword](w)
373 if nl.depth<allow_defs:
374 complain("New definitions not allowed at "
375 "level %d"%nl.depth)
376 # we risk crashing if we continue
377 sys.exit(1)
378 current.children[w[1]]=nl
379 current=nl
380 obstack.append(current)
381 return [i]
382 if current.allow_properties.has_key(keyword):
383 set_property(current,w)
384 return [i]
385 else:
386 complain("Property %s not allowed at %s level"%
387 (keyword,current.type))
388 return []
389
390 complain("unknown keyword '%s'"%(keyword))
391
392 def pfilepath(pathname,allow_include=False):
393 f=open(pathname)
394 outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
395 f.close()
396 return outlines
397
398 def pfile(name,lines,allow_include=False):
399 "Process a file"
400 global file,line
401 file=name
402 line=0
403 outlines=[]
404 for i in lines:
405 line=line+1
406 if (i[0]=='#'): continue
407 outlines += pline(i,allow_include=allow_include)
408 return outlines
409
410 def outputsites(w):
411 "Output include file for secnet configuration"
412 w.write("# secnet sites file autogenerated by make-secnet-sites "
413 +"version %s\n"%VERSION)
414 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
415 w.write("# Command line: %s\n\n"%string.join(sys.argv))
416
417 # Raw VPN data section of file
418 w.write(prefix+"vpn-data {\n")
419 for i in root.children.values():
420 i.output_data(w,2,"")
421 w.write("};\n")
422
423 # Per-VPN flattened lists
424 w.write(prefix+"vpn {\n")
425 for i in root.children.values():
426 i.output_vpnflat(w,2,prefix+"vpn-data")
427 w.write("};\n")
428
429 # Flattened list of sites
430 w.write(prefix+"all-sites %s;\n"%string.join(
431 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
432 root.children.keys()),","))
433
434 # Are we being invoked from userv?
435 service=0
436 # If we are, which group does the caller want to modify?
437 group=None
438
439 line=0
440 file=None
441 complaints=0
442
443 if len(sys.argv)<2:
444 pfile("stdin",sys.stdin.readlines())
445 of=sys.stdout
446 else:
447 if sys.argv[1]=='-u':
448 if len(sys.argv)!=6:
449 print "Wrong number of arguments"
450 sys.exit(1)
451 service=1
452 header=sys.argv[2]
453 groupfiledir=sys.argv[3]
454 sitesfile=sys.argv[4]
455 group=sys.argv[5]
456 if not os.environ.has_key("USERV_USER"):
457 print "Environment variable USERV_USER not found"
458 sys.exit(1)
459 user=os.environ["USERV_USER"]
460 # Check that group is in USERV_GROUP
461 if not os.environ.has_key("USERV_GROUP"):
462 print "Environment variable USERV_GROUP not found"
463 sys.exit(1)
464 ugs=os.environ["USERV_GROUP"]
465 ok=0
466 for i in string.split(ugs):
467 if group==i: ok=1
468 if not ok:
469 print "caller not in group %s"%group
470 sys.exit(1)
471 headerinput=pfilepath(header,allow_include=True)
472 userinput=sys.stdin.readlines()
473 pfile("user input",userinput)
474 else:
475 if sys.argv[1]=='-P':
476 prefix=sys.argv[2]
477 sys.argv[1:3]=[]
478 if len(sys.argv)>3:
479 print "Too many arguments"
480 sys.exit(1)
481 pfilepath(sys.argv[1])
482 of=sys.stdout
483 if len(sys.argv)>2:
484 of=open(sys.argv[2],'w')
485
486 # Sanity check section
487 # Delete nodes where leaf=0 that have no children
488
489 def live(n):
490 "Number of leafnodes below node n"
491 if n.leaf: return 1
492 for i in n.children.keys():
493 if live(n.children[i]): return 1
494 return 0
495 def delempty(n):
496 "Delete nodes that have no leafnode children"
497 for i in n.children.keys():
498 delempty(n.children[i])
499 if not live(n.children[i]):
500 del n.children[i]
501 delempty(root)
502
503 # Check that all constraints are met (as far as I can tell
504 # restrict-nets/networks/peer are the only special cases)
505
506 def checkconstraints(n,p,ra):
507 new_p=p.copy()
508 new_p.update(n.properties)
509 for i in n.require_properties.keys():
510 if not new_p.has_key(i):
511 moan("%s %s is missing property %s"%
512 (n.type,n.name,i))
513 for i in new_p.keys():
514 if not n.allow_properties.has_key(i):
515 moan("%s %s has forbidden property %s"%
516 (n.type,n.name,i))
517 # Check address range restrictions
518 if n.properties.has_key("restrict-nets"):
519 new_ra=ra.intersection(n.properties["restrict-nets"].set)
520 else:
521 new_ra=ra
522 if n.properties.has_key("networks"):
523 if not n.properties["networks"].set <= new_ra:
524 moan("%s %s networks out of bounds"%(n.type,n.name))
525 if n.properties.has_key("peer"):
526 if not n.properties["networks"].set.contains(
527 n.properties["peer"].addr):
528 moan("%s %s peer not in networks"%(n.type,n.name))
529 for i in n.children.keys():
530 checkconstraints(n.children[i],new_p,new_ra)
531
532 checkconstraints(root,{},ipaddrset.complete_set())
533
534 if complaints>0:
535 if complaints==1: print "There was 1 problem."
536 else: print "There were %d problems."%(complaints)
537 sys.exit(1)
538
539 if service:
540 # Put the user's input into their group file, and rebuild the main
541 # sites file
542 f=open(groupfiledir+"/T"+group,'w')
543 f.write("# Section submitted by user %s, %s\n"%
544 (user,time.asctime(time.localtime(time.time()))))
545 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
546 for i in userinput: f.write(i)
547 f.write("\n")
548 f.close()
549 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
550 f=open(sitesfile+"-tmp",'w')
551 f.write("# sites file autogenerated by make-secnet-sites\n")
552 f.write("# generated %s, invoked by %s\n"%
553 (time.asctime(time.localtime(time.time())),user))
554 f.write("# use make-secnet-sites to turn this file into a\n")
555 f.write("# valid /etc/secnet/sites.conf file\n\n")
556 for i in headerinput: f.write(i)
557 files=os.listdir(groupfiledir)
558 for i in files:
559 if i[0]=='R':
560 j=open(groupfiledir+"/"+i)
561 f.write(j.read())
562 j.close()
563 f.write("# end of sites file\n")
564 f.close()
565 os.rename(sitesfile+"-tmp",sitesfile)
566 else:
567 outputsites(of)