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