make-secnet-sites: Introduce a superclass for the config types.
[secnet] / make-secnet-sites
CommitLineData
3454dce4 1#! /usr/bin/env python
3454dce4 2#
c215a4bc
IJ
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
9c6a8729 8# the Free Software Foundation; either version 3 of the License, or
3454dce4
SE
9# (at your option) any later version.
10#
c215a4bc
IJ
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.
3454dce4
SE
15#
16# You should have received a copy of the GNU General Public License
c215a4bc
IJ
17# version 3 along with secnet; if not, see
18# https://www.gnu.org/licenses/gpl.html.
3454dce4
SE
19
20"""VPN sites file manipulation.
21
22This program enables VPN site descriptions to be submitted for
23inclusion in a central database, and allows the resulting database to
24be turned into a secnet configuration file.
25
26A database file can be turned into a secnet configuration file simply:
27make-secnet-sites.py [infile [outfile]]
28
29It would be wise to run secnet with the "--just-check-config" option
30before installing the output on a live system.
31
32The program expects to be invoked via userv to manage the database; it
33relies on the USERV_USER and USERV_GROUP environment variables. The
34command line arguments for this invocation are:
35
36make-secnet-sites.py -u header-filename groupfiles-directory output-file \
37 group
38
39All but the last argument are expected to be set by userv; the 'group'
40argument is provided by the user. A suitable userv configuration file
41fragment is:
42
43reset
44no-disconnect-hup
45no-suppress-args
46cd ~/secnet/sites-test/
08f344d3 47execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
3454dce4
SE
48
49This program is part of secnet. It relies on the "ipaddr" library from
50Cendio Systems AB.
51
52"""
53
54import string
55import time
56import sys
57import os
3b83c932 58import getopt
040040f3 59import re
8dea8d37 60
71d65e4c
IJ
61import ipaddr
62
90ad8cd4
IJ
63sys.path.insert(0,"/usr/local/share/secnet")
64sys.path.insert(0,"/usr/share/secnet")
71d65e4c 65import ipaddrset
3454dce4 66
00152558 67VERSION="0.1.18"
3b83c932
SE
68
69# Classes describing possible datatypes in the configuration file
70
d252f549
MW
71class basetype:
72 "Common protocol for configuration types."
73 pass
74
75class single_ipaddr (basetype):
3b83c932
SE
76 "An IP address"
77 def __init__(self,w):
71d65e4c 78 self.addr=ipaddr.IPAddress(w[1])
3b83c932 79 def __str__(self):
71d65e4c 80 return '"%s"'%self.addr
3b83c932 81
d252f549 82class networks (basetype):
3b83c932 83 "A set of IP addresses specified as a list of networks"
3454dce4 84 def __init__(self,w):
71d65e4c 85 self.set=ipaddrset.IPAddressSet()
3454dce4 86 for i in w[1:]:
71d65e4c
IJ
87 x=ipaddr.IPNetwork(i,strict=True)
88 self.set.append([x])
3b83c932 89 def __str__(self):
71d65e4c 90 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
3454dce4 91
d252f549 92class dhgroup (basetype):
3b83c932 93 "A Diffie-Hellman group"
3454dce4 94 def __init__(self,w):
b2a56f7c
SE
95 self.mod=w[1]
96 self.gen=w[2]
3b83c932
SE
97 def __str__(self):
98 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
3454dce4 99
d252f549 100class hash (basetype):
3b83c932 101 "A choice of hash function"
3454dce4 102 def __init__(self,w):
b2a56f7c 103 self.ht=w[1]
757aecff 104 if (self.ht not in ('md5', 'sha1', 'sha512')):
b2a56f7c 105 complain("unknown hash type %s"%(self.ht))
3b83c932
SE
106 def __str__(self):
107 return '%s'%(self.ht)
3454dce4 108
d252f549 109class email (basetype):
3b83c932 110 "An email address"
3454dce4 111 def __init__(self,w):
b2a56f7c 112 self.addr=w[1]
3b83c932
SE
113 def __str__(self):
114 return '<%s>'%(self.addr)
3454dce4 115
d252f549 116class boolean (basetype):
040040f3
IJ
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
d252f549 128class num (basetype):
3b83c932 129 "A decimal number"
3454dce4 130 def __init__(self,w):
b2a56f7c 131 self.n=string.atol(w[1])
3b83c932
SE
132 def __str__(self):
133 return '%d'%(self.n)
3454dce4 134
d252f549 135class address (basetype):
3b83c932 136 "A DNS name and UDP port number"
3454dce4 137 def __init__(self,w):
b2a56f7c
SE
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")
3b83c932
SE
142 def __str__(self):
143 return '"%s"; port %d'%(self.adr,self.port)
3454dce4 144
d252f549 145class rsakey (basetype):
3b83c932 146 "An RSA public key"
3454dce4 147 def __init__(self,w):
b2a56f7c
SE
148 self.l=string.atoi(w[1])
149 self.e=w[2]
150 self.n=w[3]
3b83c932
SE
151 def __str__(self):
152 return 'rsa-public("%s","%s")'%(self.e,self.n)
153
154# Possible properties of configuration nodes
155keywords={
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"),
a25b1149 168 'address':(address,"External contact address and port"),
040040f3 169 'mobile':(boolean,"Site is mobile"),
3b83c932
SE
170}
171
172def 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
177global_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,
a25b1149 186 'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
3b83c932
SE
187}
188
189class 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
218class 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
243class 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
263class 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,
a25b1149 273 'pubkey':(lambda n,v:"key %s;\n"%v),
040040f3 274 'mobile':sp,
3b83c932
SE
275 })
276 require_properties={
277 'dh':"Diffie-Hellman group",
278 'contact':"Site admin contact address",
3b83c932
SE
279 'networks':"Networks claimed by the site",
280 'hash':"hash function",
281 'peer':"Gateway address of the site",
a25b1149 282 'pubkey':"RSA public key of the site",
3b83c932 283 }
3454dce4 284 def __init__(self,w):
3b83c932
SE
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)
305levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
306
307# Reserved vpn/location/site names
308reserved={'all-sites':None}
309reserved.update(keywords)
310reserved.update(levels)
3454dce4
SE
311
312def complain(msg):
3b83c932 313 "Complain about a particular input line"
3454dce4
SE
314 global complaints
315 print ("%s line %d: "%(file,line))+msg
316 complaints=complaints+1
317def moan(msg):
3b83c932 318 "Complain about something in general"
3454dce4
SE
319 global complaints
320 print msg;
321 complaints=complaints+1
322
3b83c932
SE
323root=level(['root','root']) # All vpns are children of this node
324obstack=[root]
325allow_defs=0 # Level above which new definitions are permitted
26f727b9 326prefix=''
3b83c932
SE
327
328def 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)
3454dce4 335
c4497add 336def pline(i,allow_include=False):
3b83c932
SE
337 "Process a configuration file line"
338 global allow_defs, obstack, root
6d8cd9b2 339 w=string.split(i.rstrip('\n'))
433b0ae8 340 if len(w)==0: return [i]
3454dce4 341 keyword=w[0]
3b83c932 342 current=obstack[len(obstack)-1]
3454dce4 343 if keyword=='end-definitions':
3b83c932
SE
344 allow_defs=sitelevel.depth
345 obstack=[root]
433b0ae8 346 return [i]
c4497add
IJ
347 if keyword=='include':
348 if not allow_include:
349 complain("include not permitted here")
433b0ae8 350 return []
c4497add
IJ
351 if len(w) != 2:
352 complain("include requires one argument")
433b0ae8 353 return []
c4497add 354 newfile=os.path.join(os.path.dirname(file),w[1])
433b0ae8 355 return pfilepath(newfile,allow_include=allow_include)
3b83c932
SE
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!")
3454dce4 374 else:
3b83c932
SE
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)
4a9b680b
IJ
381 # we risk crashing if we continue
382 sys.exit(1)
3b83c932
SE
383 current.children[w[1]]=nl
384 current=nl
385 obstack.append(current)
433b0ae8 386 return [i]
8644ac83 387 if not current.allow_properties.has_key(keyword):
3b83c932
SE
388 complain("Property %s not allowed at %s level"%
389 (keyword,current.type))
433b0ae8 390 return []
8644ac83
MW
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]
3b83c932
SE
397
398 complain("unknown keyword '%s'"%(keyword))
3454dce4 399
c4497add 400def pfilepath(pathname,allow_include=False):
9b8369e0 401 f=open(pathname)
433b0ae8 402 outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
9b8369e0 403 f.close()
433b0ae8 404 return outlines
9b8369e0 405
c4497add 406def pfile(name,lines,allow_include=False):
3b83c932 407 "Process a file"
3454dce4
SE
408 global file,line
409 file=name
410 line=0
433b0ae8 411 outlines=[]
3454dce4
SE
412 for i in lines:
413 line=line+1
414 if (i[0]=='#'): continue
433b0ae8
IJ
415 outlines += pline(i,allow_include=allow_include)
416 return outlines
3454dce4
SE
417
418def outputsites(w):
3b83c932
SE
419 "Output include file for secnet configuration"
420 w.write("# secnet sites file autogenerated by make-secnet-sites "
3454dce4 421 +"version %s\n"%VERSION)
3b83c932
SE
422 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
423 w.write("# Command line: %s\n\n"%string.join(sys.argv))
3454dce4
SE
424
425 # Raw VPN data section of file
26f727b9 426 w.write(prefix+"vpn-data {\n")
3b83c932
SE
427 for i in root.children.values():
428 i.output_data(w,2,"")
3454dce4
SE
429 w.write("};\n")
430
431 # Per-VPN flattened lists
26f727b9 432 w.write(prefix+"vpn {\n")
3b83c932 433 for i in root.children.values():
26f727b9 434 i.output_vpnflat(w,2,prefix+"vpn-data")
3454dce4
SE
435 w.write("};\n")
436
437 # Flattened list of sites
26f727b9
IJ
438 w.write(prefix+"all-sites %s;\n"%string.join(
439 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
440 root.children.keys()),","))
3454dce4
SE
441
442# Are we being invoked from userv?
443service=0
444# If we are, which group does the caller want to modify?
445group=None
446
3454dce4
SE
447line=0
448file=None
449complaints=0
450
3454dce4
SE
451if len(sys.argv)<2:
452 pfile("stdin",sys.stdin.readlines())
453 of=sys.stdout
454else:
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)
5b77d1a9 479 headerinput=pfilepath(header,allow_include=True)
3454dce4
SE
480 userinput=sys.stdin.readlines()
481 pfile("user input",userinput)
482 else:
26f727b9
IJ
483 if sys.argv[1]=='-P':
484 prefix=sys.argv[2]
485 sys.argv[1:3]=[]
3454dce4
SE
486 if len(sys.argv)>3:
487 print "Too many arguments"
488 sys.exit(1)
21fd3a92 489 pfilepath(sys.argv[1])
3454dce4
SE
490 of=sys.stdout
491 if len(sys.argv)>2:
492 of=open(sys.argv[2],'w')
493
494# Sanity check section
3b83c932
SE
495# Delete nodes where leaf=0 that have no children
496
497def 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
503def 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]
509delempty(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
514def 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)
3454dce4 528 else:
3b83c932
SE
529 new_ra=ra
530 if n.properties.has_key("networks"):
71d65e4c 531 if not n.properties["networks"].set <= new_ra:
3b83c932
SE
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
71d65e4c 540checkconstraints(root,{},ipaddrset.complete_set())
3454dce4
SE
541
542if complaints>0:
543 if complaints==1: print "There was 1 problem."
544 else: print "There were %d problems."%(complaints)
545 sys.exit(1)
546
547if service:
548 # Put the user's input into their group file, and rebuild the main
549 # sites file
08f344d3 550 f=open(groupfiledir+"/T"+group,'w')
3454dce4
SE
551 f.write("# Section submitted by user %s, %s\n"%
552 (user,time.asctime(time.localtime(time.time()))))
3b83c932 553 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
3454dce4
SE
554 for i in userinput: f.write(i)
555 f.write("\n")
556 f.close()
08f344d3
SE
557 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
558 f=open(sitesfile+"-tmp",'w')
ff05a229 559 f.write("# sites file autogenerated by make-secnet-sites\n")
08f344d3
SE
560 f.write("# generated %s, invoked by %s\n"%
561 (time.asctime(time.localtime(time.time())),user))
ff05a229 562 f.write("# use make-secnet-sites to turn this file into a\n")
08f344d3
SE
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)
3454dce4
SE
574else:
575 outputsites(of)