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