make-secnet-sites: If definition found in wrong place, bomb out
[secnet] / make-secnet-sites
CommitLineData
3454dce4 1#! /usr/bin/env python
3b83c932 2# Copyright (C) 2001-2002 Stephen Early <steve@greenend.org.uk>
3454dce4
SE
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
20This program enables VPN site descriptions to be submitted for
21inclusion in a central database, and allows the resulting database to
22be turned into a secnet configuration file.
23
24A database file can be turned into a secnet configuration file simply:
25make-secnet-sites.py [infile [outfile]]
26
27It would be wise to run secnet with the "--just-check-config" option
28before installing the output on a live system.
29
30The program expects to be invoked via userv to manage the database; it
31relies on the USERV_USER and USERV_GROUP environment variables. The
32command line arguments for this invocation are:
33
34make-secnet-sites.py -u header-filename groupfiles-directory output-file \
35 group
36
37All but the last argument are expected to be set by userv; the 'group'
38argument is provided by the user. A suitable userv configuration file
39fragment is:
40
41reset
42no-disconnect-hup
43no-suppress-args
44cd ~/secnet/sites-test/
08f344d3 45execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
3454dce4
SE
46
47This program is part of secnet. It relies on the "ipaddr" library from
48Cendio Systems AB.
49
50"""
51
52import string
53import time
54import sys
55import os
3b83c932 56import getopt
040040f3 57import re
8dea8d37 58
3b83c932 59# The ipaddr library is installed as part of secnet
8dea8d37
SE
60sys.path.append("/usr/local/share/secnet")
61sys.path.append("/usr/share/secnet")
3454dce4
SE
62import ipaddr
63
00152558 64VERSION="0.1.18"
3b83c932
SE
65
66# Classes describing possible datatypes in the configuration file
67
68class 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
75class networks:
76 "A set of IP addresses specified as a list of networks"
3454dce4 77 def __init__(self,w):
3454dce4
SE
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))
3b83c932
SE
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()),",")
3454dce4
SE
87
88class dhgroup:
3b83c932 89 "A Diffie-Hellman group"
3454dce4 90 def __init__(self,w):
b2a56f7c
SE
91 self.mod=w[1]
92 self.gen=w[2]
3b83c932
SE
93 def __str__(self):
94 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
3454dce4
SE
95
96class hash:
3b83c932 97 "A choice of hash function"
3454dce4 98 def __init__(self,w):
b2a56f7c
SE
99 self.ht=w[1]
100 if (self.ht!='md5' and self.ht!='sha1'):
101 complain("unknown hash type %s"%(self.ht))
3b83c932
SE
102 def __str__(self):
103 return '%s'%(self.ht)
3454dce4
SE
104
105class email:
3b83c932 106 "An email address"
3454dce4 107 def __init__(self,w):
b2a56f7c 108 self.addr=w[1]
3b83c932
SE
109 def __str__(self):
110 return '<%s>'%(self.addr)
3454dce4 111
040040f3
IJ
112class 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
3454dce4 124class num:
3b83c932 125 "A decimal number"
3454dce4 126 def __init__(self,w):
b2a56f7c 127 self.n=string.atol(w[1])
3b83c932
SE
128 def __str__(self):
129 return '%d'%(self.n)
3454dce4
SE
130
131class address:
3b83c932 132 "A DNS name and UDP port number"
3454dce4 133 def __init__(self,w):
b2a56f7c
SE
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")
3b83c932
SE
138 def __str__(self):
139 return '"%s"; port %d'%(self.adr,self.port)
3454dce4
SE
140
141class rsakey:
3b83c932 142 "An RSA public key"
3454dce4 143 def __init__(self,w):
b2a56f7c
SE
144 self.l=string.atoi(w[1])
145 self.e=w[2]
146 self.n=w[3]
3b83c932
SE
147 def __str__(self):
148 return 'rsa-public("%s","%s")'%(self.e,self.n)
149
150# Possible properties of configuration nodes
151keywords={
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"),
a25b1149 164 'address':(address,"External contact address and port"),
040040f3 165 'mobile':(boolean,"Site is mobile"),
3b83c932
SE
166}
167
168def 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
173global_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,
a25b1149 182 'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
3b83c932
SE
183}
184
185class 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
214class 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
239class 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
259class 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,
a25b1149 269 'pubkey':(lambda n,v:"key %s;\n"%v),
fbc8bcc6 270 'address':None,
040040f3 271 'mobile':sp,
3b83c932
SE
272 })
273 require_properties={
274 'dh':"Diffie-Hellman group",
275 'contact':"Site admin contact address",
3b83c932
SE
276 'networks':"Networks claimed by the site",
277 'hash':"hash function",
278 'peer':"Gateway address of the site",
a25b1149 279 'pubkey':"RSA public key of the site",
3b83c932 280 }
3454dce4 281 def __init__(self,w):
3b83c932
SE
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)
302levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
303
304# Reserved vpn/location/site names
305reserved={'all-sites':None}
306reserved.update(keywords)
307reserved.update(levels)
3454dce4
SE
308
309def complain(msg):
3b83c932 310 "Complain about a particular input line"
3454dce4
SE
311 global complaints
312 print ("%s line %d: "%(file,line))+msg
313 complaints=complaints+1
314def moan(msg):
3b83c932 315 "Complain about something in general"
3454dce4
SE
316 global complaints
317 print msg;
318 complaints=complaints+1
319
3b83c932
SE
320root=level(['root','root']) # All vpns are children of this node
321obstack=[root]
322allow_defs=0 # Level above which new definitions are permitted
26f727b9 323prefix=''
3b83c932
SE
324
325def 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)
3454dce4 332
c4497add 333def pline(i,allow_include=False):
3b83c932
SE
334 "Process a configuration file line"
335 global allow_defs, obstack, root
3454dce4
SE
336 w=string.split(i)
337 if len(w)==0: return
338 keyword=w[0]
3b83c932 339 current=obstack[len(obstack)-1]
3454dce4 340 if keyword=='end-definitions':
3b83c932
SE
341 allow_defs=sitelevel.depth
342 obstack=[root]
3454dce4 343 return
c4497add
IJ
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 pfilepath(newfile,allow_include=allow_include)
353 return
3b83c932
SE
354 if levels.has_key(keyword):
355 # We may go up any number of levels, but only down by one
356 newdepth=levels[keyword].depth
357 currentdepth=len(obstack) # actually +1...
358 if newdepth<=currentdepth:
359 obstack=obstack[:newdepth]
360 if newdepth>currentdepth:
361 complain("May not go from level %d to level %d"%
362 (currentdepth-1,newdepth))
363 # See if it's a new one (and whether that's permitted)
364 # or an existing one
365 current=obstack[len(obstack)-1]
366 if current.children.has_key(w[1]):
367 # Not new
368 current=current.children[w[1]]
369 if service and group and current.depth==2:
370 if group!=current.group:
371 complain("Incorrect group!")
3454dce4 372 else:
3b83c932
SE
373 # New
374 # Ignore depth check for now
375 nl=levels[keyword](w)
376 if nl.depth<allow_defs:
377 complain("New definitions not allowed at "
378 "level %d"%nl.depth)
4a9b680b
IJ
379 # we risk crashing if we continue
380 sys.exit(1)
3b83c932
SE
381 current.children[w[1]]=nl
382 current=nl
383 obstack.append(current)
3454dce4 384 return
3b83c932
SE
385 if current.allow_properties.has_key(keyword):
386 set_property(current,w)
3454dce4 387 return
3454dce4 388 else:
3b83c932
SE
389 complain("Property %s not allowed at %s level"%
390 (keyword,current.type))
391 return
392
393 complain("unknown keyword '%s'"%(keyword))
3454dce4 394
c4497add 395def pfilepath(pathname,allow_include=False):
9b8369e0 396 f=open(pathname)
5b77d1a9
IJ
397 lines=f.readlines()
398 pfile(pathname,lines,allow_include=allow_include)
9b8369e0 399 f.close()
5b77d1a9 400 return lines
9b8369e0 401
c4497add 402def pfile(name,lines,allow_include=False):
3b83c932 403 "Process a file"
3454dce4
SE
404 global file,line
405 file=name
406 line=0
407 for i in lines:
408 line=line+1
409 if (i[0]=='#'): continue
410 if (i[len(i)-1]=='\n'): i=i[:len(i)-1] # strip trailing LF
c4497add 411 pline(i,allow_include=allow_include)
3454dce4
SE
412
413def outputsites(w):
3b83c932
SE
414 "Output include file for secnet configuration"
415 w.write("# secnet sites file autogenerated by make-secnet-sites "
3454dce4 416 +"version %s\n"%VERSION)
3b83c932
SE
417 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
418 w.write("# Command line: %s\n\n"%string.join(sys.argv))
3454dce4
SE
419
420 # Raw VPN data section of file
26f727b9 421 w.write(prefix+"vpn-data {\n")
3b83c932
SE
422 for i in root.children.values():
423 i.output_data(w,2,"")
3454dce4
SE
424 w.write("};\n")
425
426 # Per-VPN flattened lists
26f727b9 427 w.write(prefix+"vpn {\n")
3b83c932 428 for i in root.children.values():
26f727b9 429 i.output_vpnflat(w,2,prefix+"vpn-data")
3454dce4
SE
430 w.write("};\n")
431
432 # Flattened list of sites
26f727b9
IJ
433 w.write(prefix+"all-sites %s;\n"%string.join(
434 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
435 root.children.keys()),","))
3454dce4
SE
436
437# Are we being invoked from userv?
438service=0
439# If we are, which group does the caller want to modify?
440group=None
441
3454dce4
SE
442line=0
443file=None
444complaints=0
445
3454dce4
SE
446if len(sys.argv)<2:
447 pfile("stdin",sys.stdin.readlines())
448 of=sys.stdout
449else:
450 if sys.argv[1]=='-u':
451 if len(sys.argv)!=6:
452 print "Wrong number of arguments"
453 sys.exit(1)
454 service=1
455 header=sys.argv[2]
456 groupfiledir=sys.argv[3]
457 sitesfile=sys.argv[4]
458 group=sys.argv[5]
459 if not os.environ.has_key("USERV_USER"):
460 print "Environment variable USERV_USER not found"
461 sys.exit(1)
462 user=os.environ["USERV_USER"]
463 # Check that group is in USERV_GROUP
464 if not os.environ.has_key("USERV_GROUP"):
465 print "Environment variable USERV_GROUP not found"
466 sys.exit(1)
467 ugs=os.environ["USERV_GROUP"]
468 ok=0
469 for i in string.split(ugs):
470 if group==i: ok=1
471 if not ok:
472 print "caller not in group %s"%group
473 sys.exit(1)
5b77d1a9 474 headerinput=pfilepath(header,allow_include=True)
3454dce4
SE
475 userinput=sys.stdin.readlines()
476 pfile("user input",userinput)
477 else:
26f727b9
IJ
478 if sys.argv[1]=='-P':
479 prefix=sys.argv[2]
480 sys.argv[1:3]=[]
3454dce4
SE
481 if len(sys.argv)>3:
482 print "Too many arguments"
483 sys.exit(1)
c4497add 484 pfilepath(sys.argv[1],allow_include=True)
3454dce4
SE
485 of=sys.stdout
486 if len(sys.argv)>2:
487 of=open(sys.argv[2],'w')
488
489# Sanity check section
3b83c932
SE
490# Delete nodes where leaf=0 that have no children
491
492def live(n):
493 "Number of leafnodes below node n"
494 if n.leaf: return 1
495 for i in n.children.keys():
496 if live(n.children[i]): return 1
497 return 0
498def delempty(n):
499 "Delete nodes that have no leafnode children"
500 for i in n.children.keys():
501 delempty(n.children[i])
502 if not live(n.children[i]):
503 del n.children[i]
504delempty(root)
505
506# Check that all constraints are met (as far as I can tell
507# restrict-nets/networks/peer are the only special cases)
508
509def checkconstraints(n,p,ra):
510 new_p=p.copy()
511 new_p.update(n.properties)
512 for i in n.require_properties.keys():
513 if not new_p.has_key(i):
514 moan("%s %s is missing property %s"%
515 (n.type,n.name,i))
516 for i in new_p.keys():
517 if not n.allow_properties.has_key(i):
518 moan("%s %s has forbidden property %s"%
519 (n.type,n.name,i))
520 # Check address range restrictions
521 if n.properties.has_key("restrict-nets"):
522 new_ra=ra.intersection(n.properties["restrict-nets"].set)
3454dce4 523 else:
3b83c932
SE
524 new_ra=ra
525 if n.properties.has_key("networks"):
526 # I'd like to do this:
527 # n.properties["networks"].set.is_subset(new_ra)
528 # but there isn't an is_subset() method
529 # Instead we see if we intersect with the complement of new_ra
530 rac=new_ra.complement()
531 i=rac.intersection(n.properties["networks"].set)
532 if not i.is_empty():
533 moan("%s %s networks out of bounds"%(n.type,n.name))
534 if n.properties.has_key("peer"):
535 if not n.properties["networks"].set.contains(
536 n.properties["peer"].addr):
537 moan("%s %s peer not in networks"%(n.type,n.name))
538 for i in n.children.keys():
539 checkconstraints(n.children[i],new_p,new_ra)
540
541checkconstraints(root,{},ipaddr.complete_set)
3454dce4
SE
542
543if complaints>0:
544 if complaints==1: print "There was 1 problem."
545 else: print "There were %d problems."%(complaints)
546 sys.exit(1)
547
548if service:
549 # Put the user's input into their group file, and rebuild the main
550 # sites file
08f344d3 551 f=open(groupfiledir+"/T"+group,'w')
3454dce4
SE
552 f.write("# Section submitted by user %s, %s\n"%
553 (user,time.asctime(time.localtime(time.time()))))
3b83c932 554 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
3454dce4
SE
555 for i in userinput: f.write(i)
556 f.write("\n")
557 f.close()
08f344d3
SE
558 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
559 f=open(sitesfile+"-tmp",'w')
ff05a229 560 f.write("# sites file autogenerated by make-secnet-sites\n")
08f344d3
SE
561 f.write("# generated %s, invoked by %s\n"%
562 (time.asctime(time.localtime(time.time())),user))
ff05a229 563 f.write("# use make-secnet-sites to turn this file into a\n")
08f344d3
SE
564 f.write("# valid /etc/secnet/sites.conf file\n\n")
565 for i in headerinput: f.write(i)
566 files=os.listdir(groupfiledir)
567 for i in files:
568 if i[0]=='R':
569 j=open(groupfiledir+"/"+i)
570 f.write(j.read())
571 j.close()
572 f.write("# end of sites file\n")
573 f.close()
574 os.rename(sitesfile+"-tmp",sitesfile)
3454dce4
SE
575else:
576 outputsites(of)