2 # Copyright (C) 2001-2002 Stephen Early <steve@greenend.org.uk>
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.
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.
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
18 """VPN sites file manipulation.
20 This program enables VPN site descriptions to be submitted for
21 inclusion in a central database, and allows the resulting database to
22 be turned into a secnet configuration file.
24 A database file can be turned into a secnet configuration file simply:
25 make-secnet-sites.py [infile [outfile]]
27 It would be wise to run secnet with the "--just-check-config" option
28 before installing the output on a live system.
30 The program expects to be invoked via userv to manage the database; it
31 relies on the USERV_USER and USERV_GROUP environment variables. The
32 command line arguments for this invocation are:
34 make-secnet-sites.py -u header-filename groupfiles-directory output-file \
37 All but the last argument are expected to be set by userv; the 'group'
38 argument is provided by the user. A suitable userv configuration file
44 cd ~/secnet/sites-test/
45 execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
47 This program is part of secnet. It relies on the "ipaddr" library from
59 # The ipaddr library is installed as part of secnet
60 sys
.path
.append("/usr/local/share/secnet")
61 sys
.path
.append("/usr/share/secnet")
66 # Classes describing possible datatypes in the configuration file
71 self
.addr
=ipaddr
.ipaddr(w
[1])
73 return '"%s"'%self
.addr
.ip_str()
76 "A set of IP addresses specified as a list of networks"
78 self
.set=ipaddr
.ip_set()
81 self
.set.append(ipaddr
.network(x
[0],x
[1],
82 ipaddr
.DEMAND_NETWORK
))
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()),",")
89 "A Diffie-Hellman group"
94 return 'diffie-hellman("%s","%s")'%(self
.mod
,self
.gen
)
97 "A choice of hash function"
100 if (self
.ht
!='md5' and self
.ht
!='sha1'):
101 complain("unknown hash type %s"%(self
.ht
))
103 return '%s'%(self
.ht
)
107 def __init__(self
,w
):
110 return '<%s>'%(self
.addr
)
114 def __init__(self
,w
):
115 if re
.match('[TtYy1]',w
[1]):
117 elif re
.match('[FfNn0]',w
[1]):
120 complain("invalid boolean value");
122 return ['False','True'][self
.b
]
126 def __init__(self
,w
):
127 self
.n
=string
.atol(w
[1])
132 "A DNS name and UDP port number"
133 def __init__(self
,w
):
135 self
.port
=string
.atoi(w
[2])
136 if (self
.port
<1 or self
.port
>65535):
137 complain("invalid port number")
139 return '"%s"; port %d'%(self
.adr
,self
.port
)
143 def __init__(self
,w
):
144 self
.l
=string
.atoi(w
[1])
148 return 'rsa-public("%s","%s")'%(self
.e
,self
.n
)
150 # Possible properties of configuration nodes
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"),
169 "Simply output a property - the default case"
170 return "%s %s;\n"%(name
,value
)
172 # All levels support these properties
174 'contact':(lambda name
,value
:"# Contact email address: %s\n"%(value)
),
181 'renegotiate-time':sp
,
182 'restrict-nets':(lambda name
,value
:"# restrict-nets %s\n"%value
),
186 "A level in the configuration hierarchy"
190 require_properties
={}
191 def __init__(self
,w
):
195 def indent(self
,w
,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
]:
203 w
.write("%s"%self
.prop_out(i
))
204 def output_data(self
,w
,ind
,np
):
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
+"/")
214 class vpnlevel(level
):
215 "VPN level in the configuration hierarchy"
219 allow_properties
=global_properties
.copy()
221 'contact':"VPN admin contact address"
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"
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
)
234 w
.write("all-sites %s;\n"%
235 string
.join(self
.children
.keys(),','))
239 class locationlevel(level
):
240 "Location level in the configuration hierarchy"
244 allow_properties
=global_properties
.copy()
246 'contact':"Location admin contact address",
248 def __init__(self
,w
):
249 level
.__init__(self
,w
)
251 def output_vpnflat(self
,w
,ind
,h
):
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()),',')))
259 class sitelevel(level
):
260 "Site level (i.e. a leafnode) in the configuration hierarchy"
264 allow_properties
=global_properties
.copy()
265 allow_properties
.update({
269 'pubkey':(lambda n
,v
:"key %s;\n"%v
),
270 'address':(lambda n
,v
:"address %s;\n"%v
),
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",
281 def __init__(self
,w
):
282 level
.__init__(self
,w
)
283 def output_data(self
,w
,ind
,np
):
285 w
.write("%s {\n"%(self
.name
))
287 w
.write("name \"%s\";\n"%(np
+self
.name
))
288 self
.output_props(w
,ind
+2)
290 w
.write("link netlink {\n");
292 w
.write("routes %s;\n"%str
(self
.properties
["networks"]))
294 w
.write("ptp-address %s;\n"%str
(self
.properties
["peer"]))
300 # Levels in the configuration file
302 levels
={'vpn':vpnlevel
, 'location':locationlevel
, 'site':sitelevel
}
304 # Reserved vpn/location/site names
305 reserved
={'all-sites':None}
306 reserved
.update(keywords
)
307 reserved
.update(levels
)
310 "Complain about a particular input line"
312 print ("%s line %d: "%(file,line
))+msg
313 complaints
=complaints
+1
315 "Complain about something in general"
318 complaints
=complaints
+1
320 root
=level(['root','root']) # All vpns are children of this node
322 allow_defs
=0 # Level above which new definitions are permitted
325 def 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]))
331 obj
.properties
[w
[0]]=keywords
[w
[0]][0](w
)
333 def pline(i
,allow_include
=False):
334 "Process a configuration file line"
335 global allow_defs
, obstack
, root
336 w
=string
.split(i
.rstrip('\n'))
337 if len(w
)==0: return [i
]
339 current
=obstack
[len(obstack
)-1]
340 if keyword
=='end-definitions':
341 allow_defs
=sitelevel
.depth
344 if keyword
=='include':
345 if not allow_include
:
346 complain("include not permitted here")
349 complain("include requires one argument")
351 newfile
=os
.path
.join(os
.path
.dirname(file),w
[1])
352 return pfilepath(newfile
,allow_include
=allow_include
)
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)
364 current
=obstack
[len(obstack
)-1]
365 if current
.children
.has_key(w
[1]):
367 current
=current
.children
[w
[1]]
368 if service
and group
and current
.depth
==2:
369 if group
!=current
.group
:
370 complain("Incorrect group!")
373 # Ignore depth check for now
374 nl
=levels
[keyword
](w
)
375 if nl
.depth
<allow_defs
:
376 complain("New definitions not allowed at "
378 # we risk crashing if we continue
380 current
.children
[w
[1]]=nl
382 obstack
.append(current
)
384 if current
.allow_properties
.has_key(keyword
):
385 set_property(current
,w
)
388 complain("Property %s not allowed at %s level"%
389 (keyword
,current
.type))
392 complain("unknown keyword '%s'"%(keyword)
)
394 def pfilepath(pathname
,allow_include
=False):
396 outlines
=pfile(pathname
,f
.readlines(),allow_include
=allow_include
)
400 def pfile(name
,lines
,allow_include
=False):
408 if (i
[0]=='#'): continue
409 outlines
+= pline(i
,allow_include
=allow_include
)
413 "Output include file for secnet configuration"
414 w
.write("# secnet sites file autogenerated by make-secnet-sites "
415 +"version %s\n"%VERSION
)
416 w
.write("# %s\n"%time
.asctime(time
.localtime(time
.time())))
417 w
.write("# Command line: %s\n\n"%string
.join(sys
.argv
))
419 # Raw VPN data section of file
420 w
.write(prefix
+"vpn-data {\n")
421 for i
in root
.children
.values():
422 i
.output_data(w
,2,"")
425 # Per-VPN flattened lists
426 w
.write(prefix
+"vpn {\n")
427 for i
in root
.children
.values():
428 i
.output_vpnflat(w
,2,prefix
+"vpn-data")
431 # Flattened list of sites
432 w
.write(prefix
+"all-sites %s;\n"%string
.join(
433 map(lambda x
:"%svpn/%s/all-sites"%(prefix
,x
),
434 root
.children
.keys()),","))
436 # Are we being invoked from userv?
438 # If we are, which group does the caller want to modify?
446 pfile("stdin",sys
.stdin
.readlines())
449 if sys
.argv
[1]=='-u':
451 print "Wrong number of arguments"
455 groupfiledir
=sys
.argv
[3]
456 sitesfile
=sys
.argv
[4]
458 if not os
.environ
.has_key("USERV_USER"):
459 print "Environment variable USERV_USER not found"
461 user
=os
.environ
["USERV_USER"]
462 # Check that group is in USERV_GROUP
463 if not os
.environ
.has_key("USERV_GROUP"):
464 print "Environment variable USERV_GROUP not found"
466 ugs
=os
.environ
["USERV_GROUP"]
468 for i
in string
.split(ugs
):
471 print "caller not in group %s"%group
473 headerinput
=pfilepath(header
,allow_include
=True)
474 userinput
=sys
.stdin
.readlines()
475 pfile("user input",userinput
)
477 if sys
.argv
[1]=='-P':
481 print "Too many arguments"
483 pfilepath(sys
.argv
[1])
486 of
=open(sys
.argv
[2],'w')
488 # Sanity check section
489 # Delete nodes where leaf=0 that have no children
492 "Number of leafnodes below node n"
494 for i
in n
.children
.keys():
495 if live(n
.children
[i
]): return 1
498 "Delete nodes that have no leafnode children"
499 for i
in n
.children
.keys():
500 delempty(n
.children
[i
])
501 if not live(n
.children
[i
]):
505 # Check that all constraints are met (as far as I can tell
506 # restrict-nets/networks/peer are the only special cases)
508 def checkconstraints(n
,p
,ra
):
510 new_p
.update(n
.properties
)
511 for i
in n
.require_properties
.keys():
512 if not new_p
.has_key(i
):
513 moan("%s %s is missing property %s"%
515 for i
in new_p
.keys():
516 if not n
.allow_properties
.has_key(i
):
517 moan("%s %s has forbidden property %s"%
519 # Check address range restrictions
520 if n
.properties
.has_key("restrict-nets"):
521 new_ra
=ra
.intersection(n
.properties
["restrict-nets"].set)
524 if n
.properties
.has_key("networks"):
525 # I'd like to do this:
526 # n.properties["networks"].set.is_subset(new_ra)
527 # but there isn't an is_subset() method
528 # Instead we see if we intersect with the complement of new_ra
529 rac
=new_ra
.complement()
530 i
=rac
.intersection(n
.properties
["networks"].set)
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
)
540 checkconstraints(root
,{},ipaddr
.complete_set
)
543 if complaints
==1: print "There was 1 problem."
544 else: print "There were %d problems."%(complaints)
548 # Put the user's input into their group file, and rebuild the main
550 f
=open(groupfiledir
+"/T"+group
,'w')
551 f
.write("# Section submitted by user %s, %s\n"%
552 (user
,time
.asctime(time
.localtime(time
.time()))))
553 f
.write("# Checked by make-secnet-sites version %s\n\n"%VERSION
)
554 for i
in userinput
: f
.write(i
)
557 os
.rename(groupfiledir
+"/T"+group
,groupfiledir
+"/R"+group
)
558 f
=open(sitesfile
+"-tmp",'w')
559 f
.write("# sites file autogenerated by make-secnet-sites\n")
560 f
.write("# generated %s, invoked by %s\n"%
561 (time
.asctime(time
.localtime(time
.time())),user
))
562 f
.write("# use make-secnet-sites to turn this file into a\n")
563 f
.write("# valid /etc/secnet/sites.conf file\n\n")
564 for i
in headerinput
: f
.write(i
)
565 files
=os
.listdir(groupfiledir
)
568 j
=open(groupfiledir
+"/"+i
)
571 f
.write("# end of sites file\n")
573 os
.rename(sitesfile
+"-tmp",sitesfile
)