Makefile.in: Drop dist target
[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
71class single_ipaddr:
72 "An IP address"
73 def __init__(self,w):
71d65e4c 74 self.addr=ipaddr.IPAddress(w[1])
3b83c932 75 def __str__(self):
71d65e4c 76 return '"%s"'%self.addr
3b83c932
SE
77
78class networks:
79 "A set of IP addresses specified as a list of networks"
3454dce4 80 def __init__(self,w):
71d65e4c 81 self.set=ipaddrset.IPAddressSet()
3454dce4 82 for i in w[1:]:
71d65e4c
IJ
83 x=ipaddr.IPNetwork(i,strict=True)
84 self.set.append([x])
3b83c932 85 def __str__(self):
71d65e4c 86 return ",".join(map((lambda n: '"%s"'%n), self.set.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),
040040f3 270 'mobile':sp,
3b83c932
SE
271 })
272 require_properties={
273 'dh':"Diffie-Hellman group",
274 'contact':"Site admin contact address",
3b83c932
SE
275 'networks':"Networks claimed by the site",
276 'hash':"hash function",
277 'peer':"Gateway address of the site",
a25b1149 278 'pubkey':"RSA public key of the site",
3b83c932 279 }
3454dce4 280 def __init__(self,w):
3b83c932
SE
281 level.__init__(self,w)
282 def output_data(self,w,ind,np):
283 self.indent(w,ind)
284 w.write("%s {\n"%(self.name))
285 self.indent(w,ind+2)
286 w.write("name \"%s\";\n"%(np+self.name))
287 self.output_props(w,ind+2)
288 self.indent(w,ind+2)
289 w.write("link netlink {\n");
290 self.indent(w,ind+4)
291 w.write("routes %s;\n"%str(self.properties["networks"]))
292 self.indent(w,ind+4)
293 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
294 self.indent(w,ind+2)
295 w.write("};\n")
296 self.indent(w,ind)
297 w.write("};\n")
298
299# Levels in the configuration file
300# (depth,properties)
301levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
302
303# Reserved vpn/location/site names
304reserved={'all-sites':None}
305reserved.update(keywords)
306reserved.update(levels)
3454dce4
SE
307
308def complain(msg):
3b83c932 309 "Complain about a particular input line"
3454dce4
SE
310 global complaints
311 print ("%s line %d: "%(file,line))+msg
312 complaints=complaints+1
313def moan(msg):
3b83c932 314 "Complain about something in general"
3454dce4
SE
315 global complaints
316 print msg;
317 complaints=complaints+1
318
3b83c932
SE
319root=level(['root','root']) # All vpns are children of this node
320obstack=[root]
321allow_defs=0 # Level above which new definitions are permitted
26f727b9 322prefix=''
3b83c932
SE
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)
3454dce4 331
c4497add 332def pline(i,allow_include=False):
3b83c932
SE
333 "Process a configuration file line"
334 global allow_defs, obstack, root
6d8cd9b2 335 w=string.split(i.rstrip('\n'))
433b0ae8 336 if len(w)==0: return [i]
3454dce4 337 keyword=w[0]
3b83c932 338 current=obstack[len(obstack)-1]
3454dce4 339 if keyword=='end-definitions':
3b83c932
SE
340 allow_defs=sitelevel.depth
341 obstack=[root]
433b0ae8 342 return [i]
c4497add
IJ
343 if keyword=='include':
344 if not allow_include:
345 complain("include not permitted here")
433b0ae8 346 return []
c4497add
IJ
347 if len(w) != 2:
348 complain("include requires one argument")
433b0ae8 349 return []
c4497add 350 newfile=os.path.join(os.path.dirname(file),w[1])
433b0ae8 351 return pfilepath(newfile,allow_include=allow_include)
3b83c932
SE
352 if levels.has_key(keyword):
353 # We may go up any number of levels, but only down by one
354 newdepth=levels[keyword].depth
355 currentdepth=len(obstack) # actually +1...
356 if newdepth<=currentdepth:
357 obstack=obstack[:newdepth]
358 if newdepth>currentdepth:
359 complain("May not go from level %d to level %d"%
360 (currentdepth-1,newdepth))
361 # See if it's a new one (and whether that's permitted)
362 # or an existing one
363 current=obstack[len(obstack)-1]
364 if current.children.has_key(w[1]):
365 # Not new
366 current=current.children[w[1]]
367 if service and group and current.depth==2:
368 if group!=current.group:
369 complain("Incorrect group!")
3454dce4 370 else:
3b83c932
SE
371 # New
372 # Ignore depth check for now
373 nl=levels[keyword](w)
374 if nl.depth<allow_defs:
375 complain("New definitions not allowed at "
376 "level %d"%nl.depth)
4a9b680b
IJ
377 # we risk crashing if we continue
378 sys.exit(1)
3b83c932
SE
379 current.children[w[1]]=nl
380 current=nl
381 obstack.append(current)
433b0ae8 382 return [i]
8644ac83 383 if not current.allow_properties.has_key(keyword):
3b83c932
SE
384 complain("Property %s not allowed at %s level"%
385 (keyword,current.type))
433b0ae8 386 return []
8644ac83
MW
387 elif current.depth == vpnlevel.depth < allow_defs:
388 complain("Not allowed to set VPN properties here")
389 return []
390 else:
391 set_property(current,w)
392 return [i]
3b83c932
SE
393
394 complain("unknown keyword '%s'"%(keyword))
3454dce4 395
c4497add 396def pfilepath(pathname,allow_include=False):
9b8369e0 397 f=open(pathname)
433b0ae8 398 outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
9b8369e0 399 f.close()
433b0ae8 400 return outlines
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
433b0ae8 407 outlines=[]
3454dce4
SE
408 for i in lines:
409 line=line+1
410 if (i[0]=='#'): continue
433b0ae8
IJ
411 outlines += pline(i,allow_include=allow_include)
412 return outlines
3454dce4
SE
413
414def outputsites(w):
3b83c932
SE
415 "Output include file for secnet configuration"
416 w.write("# secnet sites file autogenerated by make-secnet-sites "
3454dce4 417 +"version %s\n"%VERSION)
3b83c932
SE
418 w.write("# %s\n"%time.asctime(time.localtime(time.time())))
419 w.write("# Command line: %s\n\n"%string.join(sys.argv))
3454dce4
SE
420
421 # Raw VPN data section of file
26f727b9 422 w.write(prefix+"vpn-data {\n")
3b83c932
SE
423 for i in root.children.values():
424 i.output_data(w,2,"")
3454dce4
SE
425 w.write("};\n")
426
427 # Per-VPN flattened lists
26f727b9 428 w.write(prefix+"vpn {\n")
3b83c932 429 for i in root.children.values():
26f727b9 430 i.output_vpnflat(w,2,prefix+"vpn-data")
3454dce4
SE
431 w.write("};\n")
432
433 # Flattened list of sites
26f727b9
IJ
434 w.write(prefix+"all-sites %s;\n"%string.join(
435 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
436 root.children.keys()),","))
3454dce4
SE
437
438# Are we being invoked from userv?
439service=0
440# If we are, which group does the caller want to modify?
441group=None
442
3454dce4
SE
443line=0
444file=None
445complaints=0
446
3454dce4
SE
447if len(sys.argv)<2:
448 pfile("stdin",sys.stdin.readlines())
449 of=sys.stdout
450else:
451 if sys.argv[1]=='-u':
452 if len(sys.argv)!=6:
453 print "Wrong number of arguments"
454 sys.exit(1)
455 service=1
456 header=sys.argv[2]
457 groupfiledir=sys.argv[3]
458 sitesfile=sys.argv[4]
459 group=sys.argv[5]
460 if not os.environ.has_key("USERV_USER"):
461 print "Environment variable USERV_USER not found"
462 sys.exit(1)
463 user=os.environ["USERV_USER"]
464 # Check that group is in USERV_GROUP
465 if not os.environ.has_key("USERV_GROUP"):
466 print "Environment variable USERV_GROUP not found"
467 sys.exit(1)
468 ugs=os.environ["USERV_GROUP"]
469 ok=0
470 for i in string.split(ugs):
471 if group==i: ok=1
472 if not ok:
473 print "caller not in group %s"%group
474 sys.exit(1)
5b77d1a9 475 headerinput=pfilepath(header,allow_include=True)
3454dce4
SE
476 userinput=sys.stdin.readlines()
477 pfile("user input",userinput)
478 else:
26f727b9
IJ
479 if sys.argv[1]=='-P':
480 prefix=sys.argv[2]
481 sys.argv[1:3]=[]
3454dce4
SE
482 if len(sys.argv)>3:
483 print "Too many arguments"
484 sys.exit(1)
21fd3a92 485 pfilepath(sys.argv[1])
3454dce4
SE
486 of=sys.stdout
487 if len(sys.argv)>2:
488 of=open(sys.argv[2],'w')
489
490# Sanity check section
3b83c932
SE
491# Delete nodes where leaf=0 that have no children
492
493def live(n):
494 "Number of leafnodes below node n"
495 if n.leaf: return 1
496 for i in n.children.keys():
497 if live(n.children[i]): return 1
498 return 0
499def delempty(n):
500 "Delete nodes that have no leafnode children"
501 for i in n.children.keys():
502 delempty(n.children[i])
503 if not live(n.children[i]):
504 del n.children[i]
505delempty(root)
506
507# Check that all constraints are met (as far as I can tell
508# restrict-nets/networks/peer are the only special cases)
509
510def checkconstraints(n,p,ra):
511 new_p=p.copy()
512 new_p.update(n.properties)
513 for i in n.require_properties.keys():
514 if not new_p.has_key(i):
515 moan("%s %s is missing property %s"%
516 (n.type,n.name,i))
517 for i in new_p.keys():
518 if not n.allow_properties.has_key(i):
519 moan("%s %s has forbidden property %s"%
520 (n.type,n.name,i))
521 # Check address range restrictions
522 if n.properties.has_key("restrict-nets"):
523 new_ra=ra.intersection(n.properties["restrict-nets"].set)
3454dce4 524 else:
3b83c932
SE
525 new_ra=ra
526 if n.properties.has_key("networks"):
71d65e4c 527 if not n.properties["networks"].set <= new_ra:
3b83c932
SE
528 moan("%s %s networks out of bounds"%(n.type,n.name))
529 if n.properties.has_key("peer"):
530 if not n.properties["networks"].set.contains(
531 n.properties["peer"].addr):
532 moan("%s %s peer not in networks"%(n.type,n.name))
533 for i in n.children.keys():
534 checkconstraints(n.children[i],new_p,new_ra)
535
71d65e4c 536checkconstraints(root,{},ipaddrset.complete_set())
3454dce4
SE
537
538if complaints>0:
539 if complaints==1: print "There was 1 problem."
540 else: print "There were %d problems."%(complaints)
541 sys.exit(1)
542
543if service:
544 # Put the user's input into their group file, and rebuild the main
545 # sites file
08f344d3 546 f=open(groupfiledir+"/T"+group,'w')
3454dce4
SE
547 f.write("# Section submitted by user %s, %s\n"%
548 (user,time.asctime(time.localtime(time.time()))))
3b83c932 549 f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
3454dce4
SE
550 for i in userinput: f.write(i)
551 f.write("\n")
552 f.close()
08f344d3
SE
553 os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
554 f=open(sitesfile+"-tmp",'w')
ff05a229 555 f.write("# sites file autogenerated by make-secnet-sites\n")
08f344d3
SE
556 f.write("# generated %s, invoked by %s\n"%
557 (time.asctime(time.localtime(time.time())),user))
ff05a229 558 f.write("# use make-secnet-sites to turn this file into a\n")
08f344d3
SE
559 f.write("# valid /etc/secnet/sites.conf file\n\n")
560 for i in headerinput: f.write(i)
561 files=os.listdir(groupfiledir)
562 for i in files:
563 if i[0]=='R':
564 j=open(groupfiledir+"/"+i)
565 f.write(j.read())
566 j.close()
567 f.write("# end of sites file\n")
568 f.close()
569 os.rename(sitesfile+"-tmp",sitesfile)
3454dce4
SE
570else:
571 outputsites(of)