make-secnet-sites: Remove duplicate `address' entry in sitelevel.
[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 single_ipaddr:
72 "An IP address"
73 def __init__(self,w):
74 self.addr=ipaddr.IPAddress(w[1])
75 def __str__(self):
76 return '"%s"'%self.addr
77
78 class networks:
79 "A set of IP addresses specified as a list of networks"
80 def __init__(self,w):
81 self.set=ipaddrset.IPAddressSet()
82 for i in w[1:]:
83 x=ipaddr.IPNetwork(i,strict=True)
84 self.set.append([x])
85 def __str__(self):
86 return ",".join(map((lambda n: '"%s"'%n), self.set.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 'mobile':sp,
271 })
272 require_properties={
273 'dh':"Diffie-Hellman group",
274 'contact':"Site admin contact address",
275 'networks':"Networks claimed by the site",
276 'hash':"hash function",
277 'peer':"Gateway address of the site",
278 'pubkey':"RSA public key of the site",
279 }
280 def __init__(self,w):
281 level.__init__(self,w)
282 def output_data(self,w,ind,np):
283 self.indent(w,ind)
284 w.write("%s {\n"%(self.name))
285 self.indent(w,ind+2)
286 w.write("name \"%s\";\n"%(np+self.name))
287 self.output_props(w,ind+2)
288 self.indent(w,ind+2)
289 w.write("link netlink {\n");
290 self.indent(w,ind+4)
291 w.write("routes %s;\n"%str(self.properties["networks"]))
292 self.indent(w,ind+4)
293 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
294 self.indent(w,ind+2)
295 w.write("};\n")
296 self.indent(w,ind)
297 w.write("};\n")
298
299 # Levels in the configuration file
300 # (depth,properties)
301 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
302
303 # Reserved vpn/location/site names
304 reserved={'all-sites':None}
305 reserved.update(keywords)
306 reserved.update(levels)
307
308 def complain(msg):
309 "Complain about a particular input line"
310 global complaints
311 print ("%s line %d: "%(file,line))+msg
312 complaints=complaints+1
313 def moan(msg):
314 "Complain about something in general"
315 global complaints
316 print msg;
317 complaints=complaints+1
318
319 root=level(['root','root']) # All vpns are children of this node
320 obstack=[root]
321 allow_defs=0 # Level above which new definitions are permitted
322 prefix=''
323
324 def set_property(obj,w):
325 "Set a property on a configuration node"
326 if obj.properties.has_key(w[0]):
327 complain("%s %s already has property %s defined"%
328 (obj.type,obj.name,w[0]))
329 else:
330 obj.properties[w[0]]=keywords[w[0]][0](w)
331
332 def pline(i,allow_include=False):
333 "Process a configuration file line"
334 global allow_defs, obstack, root
335 w=string.split(i.rstrip('\n'))
336 if len(w)==0: return [i]
337 keyword=w[0]
338 current=obstack[len(obstack)-1]
339 if keyword=='end-definitions':
340 allow_defs=sitelevel.depth
341 obstack=[root]
342 return [i]
343 if keyword=='include':
344 if not allow_include:
345 complain("include not permitted here")
346 return []
347 if len(w) != 2:
348 complain("include requires one argument")
349 return []
350 newfile=os.path.join(os.path.dirname(file),w[1])
351 return pfilepath(newfile,allow_include=allow_include)
352 if levels.has_key(keyword):
353 # We may go up any number of levels, but only down by one
354 newdepth=levels[keyword].depth
355 currentdepth=len(obstack) # actually +1...
356 if newdepth<=currentdepth:
357 obstack=obstack[:newdepth]
358 if newdepth>currentdepth:
359 complain("May not go from level %d to level %d"%
360 (currentdepth-1,newdepth))
361 # See if it's a new one (and whether that's permitted)
362 # or an existing one
363 current=obstack[len(obstack)-1]
364 if current.children.has_key(w[1]):
365 # Not new
366 current=current.children[w[1]]
367 if service and group and current.depth==2:
368 if group!=current.group:
369 complain("Incorrect group!")
370 else:
371 # New
372 # Ignore depth check for now
373 nl=levels[keyword](w)
374 if nl.depth<allow_defs:
375 complain("New definitions not allowed at "
376 "level %d"%nl.depth)
377 # we risk crashing if we continue
378 sys.exit(1)
379 current.children[w[1]]=nl
380 current=nl
381 obstack.append(current)
382 return [i]
383 if current.allow_properties.has_key(keyword):
384 set_property(current,w)
385 return [i]
386 else:
387 complain("Property %s not allowed at %s level"%
388 (keyword,current.type))
389 return []
390
391 complain("unknown keyword '%s'"%(keyword))
392
393 def pfilepath(pathname,allow_include=False):
394 f=open(pathname)
395 outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
396 f.close()
397 return outlines
398
399 def pfile(name,lines,allow_include=False):
400 "Process a file"
401 global file,line
402 file=name
403 line=0
404 outlines=[]
405 for i in lines:
406 line=line+1
407 if (i[0]=='#'): continue
408 outlines += pline(i,allow_include=allow_include)
409 return outlines
410
411 def outputsites(w):
412 "Output include file for secnet configuration"
413 w.write("# secnet sites file autogenerated by make-secnet-sites "
414 +"version %s\n"%VERSION)
415 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
416 w.write("# Command line: %s\n\n"%string.join(sys.argv))
417
418 # Raw VPN data section of file
419 w.write(prefix+"vpn-data {\n")
420 for i in root.children.values():
421 i.output_data(w,2,"")
422 w.write("};\n")
423
424 # Per-VPN flattened lists
425 w.write(prefix+"vpn {\n")
426 for i in root.children.values():
427 i.output_vpnflat(w,2,prefix+"vpn-data")
428 w.write("};\n")
429
430 # Flattened list of sites
431 w.write(prefix+"all-sites %s;\n"%string.join(
432 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
433 root.children.keys()),","))
434
435 # Are we being invoked from userv?
436 service=0
437 # If we are, which group does the caller want to modify?
438 group=None
439
440 line=0
441 file=None
442 complaints=0
443
444 if len(sys.argv)<2:
445 pfile("stdin",sys.stdin.readlines())
446 of=sys.stdout
447 else:
448 if sys.argv[1]=='-u':
449 if len(sys.argv)!=6:
450 print "Wrong number of arguments"
451 sys.exit(1)
452 service=1
453 header=sys.argv[2]
454 groupfiledir=sys.argv[3]
455 sitesfile=sys.argv[4]
456 group=sys.argv[5]
457 if not os.environ.has_key("USERV_USER"):
458 print "Environment variable USERV_USER not found"
459 sys.exit(1)
460 user=os.environ["USERV_USER"]
461 # Check that group is in USERV_GROUP
462 if not os.environ.has_key("USERV_GROUP"):
463 print "Environment variable USERV_GROUP not found"
464 sys.exit(1)
465 ugs=os.environ["USERV_GROUP"]
466 ok=0
467 for i in string.split(ugs):
468 if group==i: ok=1
469 if not ok:
470 print "caller not in group %s"%group
471 sys.exit(1)
472 headerinput=pfilepath(header,allow_include=True)
473 userinput=sys.stdin.readlines()
474 pfile("user input",userinput)
475 else:
476 if sys.argv[1]=='-P':
477 prefix=sys.argv[2]
478 sys.argv[1:3]=[]
479 if len(sys.argv)>3:
480 print "Too many arguments"
481 sys.exit(1)
482 pfilepath(sys.argv[1])
483 of=sys.stdout
484 if len(sys.argv)>2:
485 of=open(sys.argv[2],'w')
486
487 # Sanity check section
488 # Delete nodes where leaf=0 that have no children
489
490 def live(n):
491 "Number of leafnodes below node n"
492 if n.leaf: return 1
493 for i in n.children.keys():
494 if live(n.children[i]): return 1
495 return 0
496 def delempty(n):
497 "Delete nodes that have no leafnode children"
498 for i in n.children.keys():
499 delempty(n.children[i])
500 if not live(n.children[i]):
501 del n.children[i]
502 delempty(root)
503
504 # Check that all constraints are met (as far as I can tell
505 # restrict-nets/networks/peer are the only special cases)
506
507 def checkconstraints(n,p,ra):
508 new_p=p.copy()
509 new_p.update(n.properties)
510 for i in n.require_properties.keys():
511 if not new_p.has_key(i):
512 moan("%s %s is missing property %s"%
513 (n.type,n.name,i))
514 for i in new_p.keys():
515 if not n.allow_properties.has_key(i):
516 moan("%s %s has forbidden property %s"%
517 (n.type,n.name,i))
518 # Check address range restrictions
519 if n.properties.has_key("restrict-nets"):
520 new_ra=ra.intersection(n.properties["restrict-nets"].set)
521 else:
522 new_ra=ra
523 if n.properties.has_key("networks"):
524 if not n.properties["networks"].set <= new_ra:
525 moan("%s %s networks out of bounds"%(n.type,n.name))
526 if n.properties.has_key("peer"):
527 if not n.properties["networks"].set.contains(
528 n.properties["peer"].addr):
529 moan("%s %s peer not in networks"%(n.type,n.name))
530 for i in n.children.keys():
531 checkconstraints(n.children[i],new_p,new_ra)
532
533 checkconstraints(root,{},ipaddrset.complete_set())
534
535 if complaints>0:
536 if complaints==1: print "There was 1 problem."
537 else: print "There were %d problems."%(complaints)
538 sys.exit(1)
539
540 if service:
541 # Put the user's input into their group file, and rebuild the main
542 # sites file
543 f=open(groupfiledir+"/T"+group,'w')
544 f.write("# Section submitted by user %s, %s\n"%
545 (user,time.asctime(time.localtime(time.time()))))
546 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
547 for i in userinput: f.write(i)
548 f.write("\n")
549 f.close()
550 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
551 f=open(sitesfile+"-tmp",'w')
552 f.write("# sites file autogenerated by make-secnet-sites\n")
553 f.write("# generated %s, invoked by %s\n"%
554 (time.asctime(time.localtime(time.time())),user))
555 f.write("# use make-secnet-sites to turn this file into a\n")
556 f.write("# valid /etc/secnet/sites.conf file\n\n")
557 for i in headerinput: f.write(i)
558 files=os.listdir(groupfiledir)
559 for i in files:
560 if i[0]=='R':
561 j=open(groupfiledir+"/"+i)
562 f.write(j.read())
563 j.close()
564 f.write("# end of sites file\n")
565 f.close()
566 os.rename(sitesfile+"-tmp",sitesfile)
567 else:
568 outputsites(of)