make-secnet-sites: Introduce a superclass for the config types.
[secnet] / make-secnet-sites
1 #! /usr/bin/env python
2 #
3 # This file is part of secnet.
4 # See README for full list of copyright holders.
5 #
6 # secnet is free software; you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # secnet is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # version 3 along with secnet; if not, see
18 # https://www.gnu.org/licenses/gpl.html.
19
20 """VPN sites file manipulation.
21
22 This program enables VPN site descriptions to be submitted for
23 inclusion in a central database, and allows the resulting database to
24 be turned into a secnet configuration file.
25
26 A database file can be turned into a secnet configuration file simply:
27 make-secnet-sites.py [infile [outfile]]
28
29 It would be wise to run secnet with the "--just-check-config" option
30 before installing the output on a live system.
31
32 The program expects to be invoked via userv to manage the database; it
33 relies on the USERV_USER and USERV_GROUP environment variables. The
34 command line arguments for this invocation are:
35
36 make-secnet-sites.py -u header-filename groupfiles-directory output-file \
37 group
38
39 All but the last argument are expected to be set by userv; the 'group'
40 argument is provided by the user. A suitable userv configuration file
41 fragment is:
42
43 reset
44 no-disconnect-hup
45 no-suppress-args
46 cd ~/secnet/sites-test/
47 execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
48
49 This program is part of secnet. It relies on the "ipaddr" library from
50 Cendio Systems AB.
51
52 """
53
54 import string
55 import time
56 import sys
57 import os
58 import getopt
59 import re
60
61 import ipaddr
62
63 sys.path.insert(0,"/usr/local/share/secnet")
64 sys.path.insert(0,"/usr/share/secnet")
65 import ipaddrset
66
67 VERSION="0.1.18"
68
69 # Classes describing possible datatypes in the configuration file
70
71 class basetype:
72 "Common protocol for configuration types."
73 pass
74
75 class single_ipaddr (basetype):
76 "An IP address"
77 def __init__(self,w):
78 self.addr=ipaddr.IPAddress(w[1])
79 def __str__(self):
80 return '"%s"'%self.addr
81
82 class networks (basetype):
83 "A set of IP addresses specified as a list of networks"
84 def __init__(self,w):
85 self.set=ipaddrset.IPAddressSet()
86 for i in w[1:]:
87 x=ipaddr.IPNetwork(i,strict=True)
88 self.set.append([x])
89 def __str__(self):
90 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
91
92 class dhgroup (basetype):
93 "A Diffie-Hellman group"
94 def __init__(self,w):
95 self.mod=w[1]
96 self.gen=w[2]
97 def __str__(self):
98 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
99
100 class hash (basetype):
101 "A choice of hash function"
102 def __init__(self,w):
103 self.ht=w[1]
104 if (self.ht not in ('md5', 'sha1', 'sha512')):
105 complain("unknown hash type %s"%(self.ht))
106 def __str__(self):
107 return '%s'%(self.ht)
108
109 class email (basetype):
110 "An email address"
111 def __init__(self,w):
112 self.addr=w[1]
113 def __str__(self):
114 return '<%s>'%(self.addr)
115
116 class boolean (basetype):
117 "A boolean"
118 def __init__(self,w):
119 if re.match('[TtYy1]',w[1]):
120 self.b=True
121 elif re.match('[FfNn0]',w[1]):
122 self.b=False
123 else:
124 complain("invalid boolean value");
125 def __str__(self):
126 return ['False','True'][self.b]
127
128 class num (basetype):
129 "A decimal number"
130 def __init__(self,w):
131 self.n=string.atol(w[1])
132 def __str__(self):
133 return '%d'%(self.n)
134
135 class address (basetype):
136 "A DNS name and UDP port number"
137 def __init__(self,w):
138 self.adr=w[1]
139 self.port=string.atoi(w[2])
140 if (self.port<1 or self.port>65535):
141 complain("invalid port number")
142 def __str__(self):
143 return '"%s"; port %d'%(self.adr,self.port)
144
145 class rsakey (basetype):
146 "An RSA public key"
147 def __init__(self,w):
148 self.l=string.atoi(w[1])
149 self.e=w[2]
150 self.n=w[3]
151 def __str__(self):
152 return 'rsa-public("%s","%s")'%(self.e,self.n)
153
154 # Possible properties of configuration nodes
155 keywords={
156 'contact':(email,"Contact address"),
157 'dh':(dhgroup,"Diffie-Hellman group"),
158 'hash':(hash,"Hash function"),
159 'key-lifetime':(num,"Maximum key lifetime (ms)"),
160 'setup-timeout':(num,"Key setup timeout (ms)"),
161 'setup-retries':(num,"Maximum key setup packet retries"),
162 'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
163 'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
164 'restrict-nets':(networks,"Allowable networks"),
165 'networks':(networks,"Claimed networks"),
166 'pubkey':(rsakey,"RSA public site key"),
167 'peer':(single_ipaddr,"Tunnel peer IP address"),
168 'address':(address,"External contact address and port"),
169 'mobile':(boolean,"Site is mobile"),
170 }
171
172 def sp(name,value):
173 "Simply output a property - the default case"
174 return "%s %s;\n"%(name,value)
175
176 # All levels support these properties
177 global_properties={
178 'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
179 'dh':sp,
180 'hash':sp,
181 'key-lifetime':sp,
182 'setup-timeout':sp,
183 'setup-retries':sp,
184 'wait-time':sp,
185 'renegotiate-time':sp,
186 'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
187 }
188
189 class level:
190 "A level in the configuration hierarchy"
191 depth=0
192 leaf=0
193 allow_properties={}
194 require_properties={}
195 def __init__(self,w):
196 self.name=w[1]
197 self.properties={}
198 self.children={}
199 def indent(self,w,t):
200 w.write(" "[:t])
201 def prop_out(self,n):
202 return self.allow_properties[n](n,str(self.properties[n]))
203 def output_props(self,w,ind):
204 for i in self.properties.keys():
205 if self.allow_properties[i]:
206 self.indent(w,ind)
207 w.write("%s"%self.prop_out(i))
208 def output_data(self,w,ind,np):
209 self.indent(w,ind)
210 w.write("%s {\n"%(self.name))
211 self.output_props(w,ind+2)
212 if self.depth==1: w.write("\n");
213 for c in self.children.values():
214 c.output_data(w,ind+2,np+self.name+"/")
215 self.indent(w,ind)
216 w.write("};\n")
217
218 class vpnlevel(level):
219 "VPN level in the configuration hierarchy"
220 depth=1
221 leaf=0
222 type="vpn"
223 allow_properties=global_properties.copy()
224 require_properties={
225 'contact':"VPN admin contact address"
226 }
227 def __init__(self,w):
228 level.__init__(self,w)
229 def output_vpnflat(self,w,ind,h):
230 "Output flattened list of site names for this VPN"
231 self.indent(w,ind)
232 w.write("%s {\n"%(self.name))
233 for i in self.children.keys():
234 self.children[i].output_vpnflat(w,ind+2,
235 h+"/"+self.name+"/"+i)
236 w.write("\n")
237 self.indent(w,ind+2)
238 w.write("all-sites %s;\n"%
239 string.join(self.children.keys(),','))
240 self.indent(w,ind)
241 w.write("};\n")
242
243 class locationlevel(level):
244 "Location level in the configuration hierarchy"
245 depth=2
246 leaf=0
247 type="location"
248 allow_properties=global_properties.copy()
249 require_properties={
250 'contact':"Location admin contact address",
251 }
252 def __init__(self,w):
253 level.__init__(self,w)
254 self.group=w[2]
255 def output_vpnflat(self,w,ind,h):
256 self.indent(w,ind)
257 # The "h=h,self=self" abomination below exists because
258 # Python didn't support nested_scopes until version 2.1
259 w.write("%s %s;\n"%(self.name,string.join(
260 map(lambda x,h=h,self=self:
261 h+"/"+x,self.children.keys()),',')))
262
263 class sitelevel(level):
264 "Site level (i.e. a leafnode) in the configuration hierarchy"
265 depth=3
266 leaf=1
267 type="site"
268 allow_properties=global_properties.copy()
269 allow_properties.update({
270 'address':sp,
271 'networks':None,
272 'peer':None,
273 'pubkey':(lambda n,v:"key %s;\n"%v),
274 'mobile':sp,
275 })
276 require_properties={
277 'dh':"Diffie-Hellman group",
278 'contact':"Site admin contact address",
279 'networks':"Networks claimed by the site",
280 'hash':"hash function",
281 'peer':"Gateway address of the site",
282 'pubkey':"RSA public key of the site",
283 }
284 def __init__(self,w):
285 level.__init__(self,w)
286 def output_data(self,w,ind,np):
287 self.indent(w,ind)
288 w.write("%s {\n"%(self.name))
289 self.indent(w,ind+2)
290 w.write("name \"%s\";\n"%(np+self.name))
291 self.output_props(w,ind+2)
292 self.indent(w,ind+2)
293 w.write("link netlink {\n");
294 self.indent(w,ind+4)
295 w.write("routes %s;\n"%str(self.properties["networks"]))
296 self.indent(w,ind+4)
297 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
298 self.indent(w,ind+2)
299 w.write("};\n")
300 self.indent(w,ind)
301 w.write("};\n")
302
303 # Levels in the configuration file
304 # (depth,properties)
305 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
306
307 # Reserved vpn/location/site names
308 reserved={'all-sites':None}
309 reserved.update(keywords)
310 reserved.update(levels)
311
312 def complain(msg):
313 "Complain about a particular input line"
314 global complaints
315 print ("%s line %d: "%(file,line))+msg
316 complaints=complaints+1
317 def moan(msg):
318 "Complain about something in general"
319 global complaints
320 print msg;
321 complaints=complaints+1
322
323 root=level(['root','root']) # All vpns are children of this node
324 obstack=[root]
325 allow_defs=0 # Level above which new definitions are permitted
326 prefix=''
327
328 def set_property(obj,w):
329 "Set a property on a configuration node"
330 if obj.properties.has_key(w[0]):
331 complain("%s %s already has property %s defined"%
332 (obj.type,obj.name,w[0]))
333 else:
334 obj.properties[w[0]]=keywords[w[0]][0](w)
335
336 def pline(i,allow_include=False):
337 "Process a configuration file line"
338 global allow_defs, obstack, root
339 w=string.split(i.rstrip('\n'))
340 if len(w)==0: return [i]
341 keyword=w[0]
342 current=obstack[len(obstack)-1]
343 if keyword=='end-definitions':
344 allow_defs=sitelevel.depth
345 obstack=[root]
346 return [i]
347 if keyword=='include':
348 if not allow_include:
349 complain("include not permitted here")
350 return []
351 if len(w) != 2:
352 complain("include requires one argument")
353 return []
354 newfile=os.path.join(os.path.dirname(file),w[1])
355 return pfilepath(newfile,allow_include=allow_include)
356 if levels.has_key(keyword):
357 # We may go up any number of levels, but only down by one
358 newdepth=levels[keyword].depth
359 currentdepth=len(obstack) # actually +1...
360 if newdepth<=currentdepth:
361 obstack=obstack[:newdepth]
362 if newdepth>currentdepth:
363 complain("May not go from level %d to level %d"%
364 (currentdepth-1,newdepth))
365 # See if it's a new one (and whether that's permitted)
366 # or an existing one
367 current=obstack[len(obstack)-1]
368 if current.children.has_key(w[1]):
369 # Not new
370 current=current.children[w[1]]
371 if service and group and current.depth==2:
372 if group!=current.group:
373 complain("Incorrect group!")
374 else:
375 # New
376 # Ignore depth check for now
377 nl=levels[keyword](w)
378 if nl.depth<allow_defs:
379 complain("New definitions not allowed at "
380 "level %d"%nl.depth)
381 # we risk crashing if we continue
382 sys.exit(1)
383 current.children[w[1]]=nl
384 current=nl
385 obstack.append(current)
386 return [i]
387 if not current.allow_properties.has_key(keyword):
388 complain("Property %s not allowed at %s level"%
389 (keyword,current.type))
390 return []
391 elif current.depth == vpnlevel.depth < allow_defs:
392 complain("Not allowed to set VPN properties here")
393 return []
394 else:
395 set_property(current,w)
396 return [i]
397
398 complain("unknown keyword '%s'"%(keyword))
399
400 def pfilepath(pathname,allow_include=False):
401 f=open(pathname)
402 outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
403 f.close()
404 return outlines
405
406 def pfile(name,lines,allow_include=False):
407 "Process a file"
408 global file,line
409 file=name
410 line=0
411 outlines=[]
412 for i in lines:
413 line=line+1
414 if (i[0]=='#'): continue
415 outlines += pline(i,allow_include=allow_include)
416 return outlines
417
418 def outputsites(w):
419 "Output include file for secnet configuration"
420 w.write("# secnet sites file autogenerated by make-secnet-sites "
421 +"version %s\n"%VERSION)
422 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
423 w.write("# Command line: %s\n\n"%string.join(sys.argv))
424
425 # Raw VPN data section of file
426 w.write(prefix+"vpn-data {\n")
427 for i in root.children.values():
428 i.output_data(w,2,"")
429 w.write("};\n")
430
431 # Per-VPN flattened lists
432 w.write(prefix+"vpn {\n")
433 for i in root.children.values():
434 i.output_vpnflat(w,2,prefix+"vpn-data")
435 w.write("};\n")
436
437 # Flattened list of sites
438 w.write(prefix+"all-sites %s;\n"%string.join(
439 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
440 root.children.keys()),","))
441
442 # Are we being invoked from userv?
443 service=0
444 # If we are, which group does the caller want to modify?
445 group=None
446
447 line=0
448 file=None
449 complaints=0
450
451 if len(sys.argv)<2:
452 pfile("stdin",sys.stdin.readlines())
453 of=sys.stdout
454 else:
455 if sys.argv[1]=='-u':
456 if len(sys.argv)!=6:
457 print "Wrong number of arguments"
458 sys.exit(1)
459 service=1
460 header=sys.argv[2]
461 groupfiledir=sys.argv[3]
462 sitesfile=sys.argv[4]
463 group=sys.argv[5]
464 if not os.environ.has_key("USERV_USER"):
465 print "Environment variable USERV_USER not found"
466 sys.exit(1)
467 user=os.environ["USERV_USER"]
468 # Check that group is in USERV_GROUP
469 if not os.environ.has_key("USERV_GROUP"):
470 print "Environment variable USERV_GROUP not found"
471 sys.exit(1)
472 ugs=os.environ["USERV_GROUP"]
473 ok=0
474 for i in string.split(ugs):
475 if group==i: ok=1
476 if not ok:
477 print "caller not in group %s"%group
478 sys.exit(1)
479 headerinput=pfilepath(header,allow_include=True)
480 userinput=sys.stdin.readlines()
481 pfile("user input",userinput)
482 else:
483 if sys.argv[1]=='-P':
484 prefix=sys.argv[2]
485 sys.argv[1:3]=[]
486 if len(sys.argv)>3:
487 print "Too many arguments"
488 sys.exit(1)
489 pfilepath(sys.argv[1])
490 of=sys.stdout
491 if len(sys.argv)>2:
492 of=open(sys.argv[2],'w')
493
494 # Sanity check section
495 # Delete nodes where leaf=0 that have no children
496
497 def live(n):
498 "Number of leafnodes below node n"
499 if n.leaf: return 1
500 for i in n.children.keys():
501 if live(n.children[i]): return 1
502 return 0
503 def delempty(n):
504 "Delete nodes that have no leafnode children"
505 for i in n.children.keys():
506 delempty(n.children[i])
507 if not live(n.children[i]):
508 del n.children[i]
509 delempty(root)
510
511 # Check that all constraints are met (as far as I can tell
512 # restrict-nets/networks/peer are the only special cases)
513
514 def checkconstraints(n,p,ra):
515 new_p=p.copy()
516 new_p.update(n.properties)
517 for i in n.require_properties.keys():
518 if not new_p.has_key(i):
519 moan("%s %s is missing property %s"%
520 (n.type,n.name,i))
521 for i in new_p.keys():
522 if not n.allow_properties.has_key(i):
523 moan("%s %s has forbidden property %s"%
524 (n.type,n.name,i))
525 # Check address range restrictions
526 if n.properties.has_key("restrict-nets"):
527 new_ra=ra.intersection(n.properties["restrict-nets"].set)
528 else:
529 new_ra=ra
530 if n.properties.has_key("networks"):
531 if not n.properties["networks"].set <= new_ra:
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,{},ipaddrset.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)