make-secnet-sites: Allow sites with no address
[secnet] / make-secnet-sites
... / ...
CommitLineData
1#! /usr/bin/env python
2# Copyright (C) 2001-2002 Stephen Early <steve@greenend.org.uk>
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/
45execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
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
56import getopt
57import re
58
59# The ipaddr library is installed as part of secnet
60sys.path.append("/usr/local/share/secnet")
61sys.path.append("/usr/share/secnet")
62import ipaddr
63
64VERSION="0.1.18"
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"
77 def __init__(self,w):
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))
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()),",")
87
88class 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
96class 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
105class 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
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
124class 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
131class 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
141class 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
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"),
164 'address':(address,"External contact address and port"),
165 'mobile':(boolean,"Site is mobile"),
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,
182 'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
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,
269 'pubkey':(lambda n,v:"key %s;\n"%v),
270 'address':None,
271 'mobile':sp,
272 })
273 require_properties={
274 'dh':"Diffie-Hellman group",
275 'contact':"Site admin contact address",
276 'networks':"Networks claimed by the site",
277 'hash':"hash function",
278 'peer':"Gateway address of the site",
279 'pubkey':"RSA public key of the site",
280 }
281 def __init__(self,w):
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)
308
309def complain(msg):
310 "Complain about a particular input line"
311 global complaints
312 print ("%s line %d: "%(file,line))+msg
313 complaints=complaints+1
314def moan(msg):
315 "Complain about something in general"
316 global complaints
317 print msg;
318 complaints=complaints+1
319
320root=level(['root','root']) # All vpns are children of this node
321obstack=[root]
322allow_defs=0 # Level above which new definitions are permitted
323
324def 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
332def pline(i,allow_include=False):
333 "Process a configuration file line"
334 global allow_defs, obstack, root
335 w=string.split(i)
336 if len(w)==0: return
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
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 pfilepath(newfile,allow_include=allow_include)
352 return
353 if levels.has_key(keyword):
354 # We may go up any number of levels, but only down by one
355 newdepth=levels[keyword].depth
356 currentdepth=len(obstack) # actually +1...
357 if newdepth<=currentdepth:
358 obstack=obstack[:newdepth]
359 if newdepth>currentdepth:
360 complain("May not go from level %d to level %d"%
361 (currentdepth-1,newdepth))
362 # See if it's a new one (and whether that's permitted)
363 # or an existing one
364 current=obstack[len(obstack)-1]
365 if current.children.has_key(w[1]):
366 # Not new
367 current=current.children[w[1]]
368 if service and group and current.depth==2:
369 if group!=current.group:
370 complain("Incorrect group!")
371 else:
372 # New
373 # Ignore depth check for now
374 nl=levels[keyword](w)
375 if nl.depth<allow_defs:
376 complain("New definitions not allowed at "
377 "level %d"%nl.depth)
378 current.children[w[1]]=nl
379 current=nl
380 obstack.append(current)
381 return
382 if current.allow_properties.has_key(keyword):
383 set_property(current,w)
384 return
385 else:
386 complain("Property %s not allowed at %s level"%
387 (keyword,current.type))
388 return
389
390 complain("unknown keyword '%s'"%(keyword))
391
392def pfilepath(pathname,allow_include=False):
393 f=open(pathname)
394 lines=f.readlines()
395 pfile(pathname,lines,allow_include=allow_include)
396 f.close()
397 return lines
398
399def pfile(name,lines,allow_include=False):
400 "Process a file"
401 global file,line
402 file=name
403 line=0
404 for i in lines:
405 line=line+1
406 if (i[0]=='#'): continue
407 if (i[len(i)-1]=='\n'): i=i[:len(i)-1] # strip trailing LF
408 pline(i,allow_include=allow_include)
409
410def outputsites(w):
411 "Output include file for secnet configuration"
412 w.write("# secnet sites file autogenerated by make-secnet-sites "
413 +"version %s\n"%VERSION)
414 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
415 w.write("# Command line: %s\n\n"%string.join(sys.argv))
416
417 # Raw VPN data section of file
418 w.write("vpn-data {\n")
419 for i in root.children.values():
420 i.output_data(w,2,"")
421 w.write("};\n")
422
423 # Per-VPN flattened lists
424 w.write("vpn {\n")
425 for i in root.children.values():
426 i.output_vpnflat(w,2,"vpn-data")
427 w.write("};\n")
428
429 # Flattened list of sites
430 w.write("all-sites %s;\n"%string.join(map(lambda x:"vpn/%s/all-sites"%
431 x,root.children.keys()),","))
432
433# Are we being invoked from userv?
434service=0
435# If we are, which group does the caller want to modify?
436group=None
437
438line=0
439file=None
440complaints=0
441
442if len(sys.argv)<2:
443 pfile("stdin",sys.stdin.readlines())
444 of=sys.stdout
445else:
446 if sys.argv[1]=='-u':
447 if len(sys.argv)!=6:
448 print "Wrong number of arguments"
449 sys.exit(1)
450 service=1
451 header=sys.argv[2]
452 groupfiledir=sys.argv[3]
453 sitesfile=sys.argv[4]
454 group=sys.argv[5]
455 if not os.environ.has_key("USERV_USER"):
456 print "Environment variable USERV_USER not found"
457 sys.exit(1)
458 user=os.environ["USERV_USER"]
459 # Check that group is in USERV_GROUP
460 if not os.environ.has_key("USERV_GROUP"):
461 print "Environment variable USERV_GROUP not found"
462 sys.exit(1)
463 ugs=os.environ["USERV_GROUP"]
464 ok=0
465 for i in string.split(ugs):
466 if group==i: ok=1
467 if not ok:
468 print "caller not in group %s"%group
469 sys.exit(1)
470 headerinput=pfilepath(header,allow_include=True)
471 userinput=sys.stdin.readlines()
472 pfile("user input",userinput)
473 else:
474 if len(sys.argv)>3:
475 print "Too many arguments"
476 sys.exit(1)
477 pfilepath(sys.argv[1],allow_include=True)
478 of=sys.stdout
479 if len(sys.argv)>2:
480 of=open(sys.argv[2],'w')
481
482# Sanity check section
483# Delete nodes where leaf=0 that have no children
484
485def live(n):
486 "Number of leafnodes below node n"
487 if n.leaf: return 1
488 for i in n.children.keys():
489 if live(n.children[i]): return 1
490 return 0
491def delempty(n):
492 "Delete nodes that have no leafnode children"
493 for i in n.children.keys():
494 delempty(n.children[i])
495 if not live(n.children[i]):
496 del n.children[i]
497delempty(root)
498
499# Check that all constraints are met (as far as I can tell
500# restrict-nets/networks/peer are the only special cases)
501
502def checkconstraints(n,p,ra):
503 new_p=p.copy()
504 new_p.update(n.properties)
505 for i in n.require_properties.keys():
506 if not new_p.has_key(i):
507 moan("%s %s is missing property %s"%
508 (n.type,n.name,i))
509 for i in new_p.keys():
510 if not n.allow_properties.has_key(i):
511 moan("%s %s has forbidden property %s"%
512 (n.type,n.name,i))
513 # Check address range restrictions
514 if n.properties.has_key("restrict-nets"):
515 new_ra=ra.intersection(n.properties["restrict-nets"].set)
516 else:
517 new_ra=ra
518 if n.properties.has_key("networks"):
519 # I'd like to do this:
520 # n.properties["networks"].set.is_subset(new_ra)
521 # but there isn't an is_subset() method
522 # Instead we see if we intersect with the complement of new_ra
523 rac=new_ra.complement()
524 i=rac.intersection(n.properties["networks"].set)
525 if not i.is_empty():
526 moan("%s %s networks out of bounds"%(n.type,n.name))
527 if n.properties.has_key("peer"):
528 if not n.properties["networks"].set.contains(
529 n.properties["peer"].addr):
530 moan("%s %s peer not in networks"%(n.type,n.name))
531 for i in n.children.keys():
532 checkconstraints(n.children[i],new_p,new_ra)
533
534checkconstraints(root,{},ipaddr.complete_set)
535
536if complaints>0:
537 if complaints==1: print "There was 1 problem."
538 else: print "There were %d problems."%(complaints)
539 sys.exit(1)
540
541if service:
542 # Put the user's input into their group file, and rebuild the main
543 # sites file
544 f=open(groupfiledir+"/T"+group,'w')
545 f.write("# Section submitted by user %s, %s\n"%
546 (user,time.asctime(time.localtime(time.time()))))
547 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
548 for i in userinput: f.write(i)
549 f.write("\n")
550 f.close()
551 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
552 f=open(sitesfile+"-tmp",'w')
553 f.write("# sites file autogenerated by make-secnet-sites\n")
554 f.write("# generated %s, invoked by %s\n"%
555 (time.asctime(time.localtime(time.time())),user))
556 f.write("# use make-secnet-sites to turn this file into a\n")
557 f.write("# valid /etc/secnet/sites.conf file\n\n")
558 for i in headerinput: f.write(i)
559 files=os.listdir(groupfiledir)
560 for i in files:
561 if i[0]=='R':
562 j=open(groupfiledir+"/"+i)
563 f.write(j.read())
564 j.close()
565 f.write("# end of sites file\n")
566 f.close()
567 os.rename(sitesfile+"-tmp",sitesfile)
568else:
569 outputsites(of)