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