chiark / gitweb /
make-secnet-sites: Introduce a superclass for the config types.
[secnet.git] / make-secnet-sites
1 #! /usr/bin/env python
2 #
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
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
10
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.
15
16 # You should have received a copy of the GNU General Public License
17 # version 3 along with secnet; if not, see
18 # https://www.gnu.org/licenses/gpl.html.
19
20 """VPN sites file manipulation.
21
22 This program enables VPN site descriptions to be submitted for
23 inclusion in a central database, and allows the resulting database to
24 be turned into a secnet configuration file.
25
26 A database file can be turned into a secnet configuration file simply:
27 make-secnet-sites.py [infile [outfile]]
28
29 It would be wise to run secnet with the "--just-check-config" option
30 before installing the output on a live system.
31
32 The program expects to be invoked via userv to manage the database; it
33 relies on the USERV_USER and USERV_GROUP environment variables. The
34 command line arguments for this invocation are:
35
36 make-secnet-sites.py -u header-filename groupfiles-directory output-file \
37   group
38
39 All but the last argument are expected to be set by userv; the 'group'
40 argument is provided by the user. A suitable userv configuration file
41 fragment is:
42
43 reset
44 no-disconnect-hup
45 no-suppress-args
46 cd ~/secnet/sites-test/
47 execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
48
49 This program is part of secnet. It relies on the "ipaddr" library from
50 Cendio Systems AB.
51
52 """
53
54 import string
55 import time
56 import sys
57 import os
58 import getopt
59 import re
60
61 import ipaddr
62
63 sys.path.insert(0,"/usr/local/share/secnet")
64 sys.path.insert(0,"/usr/share/secnet")
65 import ipaddrset
66
67 VERSION="0.1.18"
68
69 # Classes describing possible datatypes in the configuration file
70
71 class basetype:
72         "Common protocol for configuration types."
73         pass
74
75 class single_ipaddr (basetype):
76         "An IP address"
77         def __init__(self,w):
78                 self.addr=ipaddr.IPAddress(w[1])
79         def __str__(self):
80                 return '"%s"'%self.addr
81
82 class networks (basetype):
83         "A set of IP addresses specified as a list of networks"
84         def __init__(self,w):
85                 self.set=ipaddrset.IPAddressSet()
86                 for i in w[1:]:
87                         x=ipaddr.IPNetwork(i,strict=True)
88                         self.set.append([x])
89         def __str__(self):
90                 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
91
92 class dhgroup (basetype):
93         "A Diffie-Hellman group"
94         def __init__(self,w):
95                 self.mod=w[1]
96                 self.gen=w[2]
97         def __str__(self):
98                 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
99
100 class hash (basetype):
101         "A choice of hash function"
102         def __init__(self,w):
103                 self.ht=w[1]
104                 if (self.ht!='md5' and self.ht!='sha1'):
105                         complain("unknown hash type %s"%(self.ht))
106         def __str__(self):
107                 return '%s'%(self.ht)
108
109 class email (basetype):
110         "An email address"
111         def __init__(self,w):
112                 self.addr=w[1]
113         def __str__(self):
114                 return '<%s>'%(self.addr)
115
116 class boolean (basetype):
117         "A boolean"
118         def __init__(self,w):
119                 if re.match('[TtYy1]',w[1]):
120                         self.b=True
121                 elif re.match('[FfNn0]',w[1]):
122                         self.b=False
123                 else:
124                         complain("invalid boolean value");
125         def __str__(self):
126                 return ['False','True'][self.b]
127
128 class num (basetype):
129         "A decimal number"
130         def __init__(self,w):
131                 self.n=string.atol(w[1])
132         def __str__(self):
133                 return '%d'%(self.n)
134
135 class address (basetype):
136         "A DNS name and UDP port number"
137         def __init__(self,w):
138                 self.adr=w[1]
139                 self.port=string.atoi(w[2])
140                 if (self.port<1 or self.port>65535):
141                         complain("invalid port number")
142         def __str__(self):
143                 return '"%s"; port %d'%(self.adr,self.port)
144
145 class rsakey (basetype):
146         "An RSA public key"
147         def __init__(self,w):
148                 self.l=string.atoi(w[1])
149                 self.e=w[2]
150                 self.n=w[3]
151         def __str__(self):
152                 return 'rsa-public("%s","%s")'%(self.e,self.n)
153
154 # Possible properties of configuration nodes
155 keywords={
156  'contact':(email,"Contact address"),
157  'dh':(dhgroup,"Diffie-Hellman group"),
158  'hash':(hash,"Hash function"),
159  'key-lifetime':(num,"Maximum key lifetime (ms)"),
160  'setup-timeout':(num,"Key setup timeout (ms)"),
161  'setup-retries':(num,"Maximum key setup packet retries"),
162  'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
163  'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
164  'restrict-nets':(networks,"Allowable networks"),
165  'networks':(networks,"Claimed networks"),
166  'pubkey':(rsakey,"RSA public site key"),
167  'peer':(single_ipaddr,"Tunnel peer IP address"),
168  'address':(address,"External contact address and port"),
169  'mobile':(boolean,"Site is mobile"),
170 }
171
172 def sp(name,value):
173         "Simply output a property - the default case"
174         return "%s %s;\n"%(name,value)
175
176 # All levels support these properties
177 global_properties={
178         'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
179         'dh':sp,
180         'hash':sp,
181         'key-lifetime':sp,
182         'setup-timeout':sp,
183         'setup-retries':sp,
184         'wait-time':sp,
185         'renegotiate-time':sp,
186         'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
187 }
188
189 class level:
190         "A level in the configuration hierarchy"
191         depth=0
192         leaf=0
193         allow_properties={}
194         require_properties={}
195         def __init__(self,w):
196                 self.name=w[1]
197                 self.properties={}
198                 self.children={}
199         def indent(self,w,t):
200                 w.write("                 "[:t])
201         def prop_out(self,n):
202                 return self.allow_properties[n](n,str(self.properties[n]))
203         def output_props(self,w,ind):
204                 for i in self.properties.keys():
205                         if self.allow_properties[i]:
206                                 self.indent(w,ind)
207                                 w.write("%s"%self.prop_out(i))
208         def output_data(self,w,ind,np):
209                 self.indent(w,ind)
210                 w.write("%s {\n"%(self.name))
211                 self.output_props(w,ind+2)
212                 if self.depth==1: w.write("\n");
213                 for c in self.children.values():
214                         c.output_data(w,ind+2,np+self.name+"/")
215                 self.indent(w,ind)
216                 w.write("};\n")
217
218 class vpnlevel(level):
219         "VPN level in the configuration hierarchy"
220         depth=1
221         leaf=0
222         type="vpn"
223         allow_properties=global_properties.copy()
224         require_properties={
225          'contact':"VPN admin contact address"
226         }
227         def __init__(self,w):
228                 level.__init__(self,w)
229         def output_vpnflat(self,w,ind,h):
230                 "Output flattened list of site names for this VPN"
231                 self.indent(w,ind)
232                 w.write("%s {\n"%(self.name))
233                 for i in self.children.keys():
234                         self.children[i].output_vpnflat(w,ind+2,
235                                 h+"/"+self.name+"/"+i)
236                 w.write("\n")
237                 self.indent(w,ind+2)
238                 w.write("all-sites %s;\n"%
239                         string.join(self.children.keys(),','))
240                 self.indent(w,ind)
241                 w.write("};\n")
242
243 class locationlevel(level):
244         "Location level in the configuration hierarchy"
245         depth=2
246         leaf=0
247         type="location"
248         allow_properties=global_properties.copy()
249         require_properties={
250          'contact':"Location admin contact address",
251         }
252         def __init__(self,w):
253                 level.__init__(self,w)
254                 self.group=w[2]
255         def output_vpnflat(self,w,ind,h):
256                 self.indent(w,ind)
257                 # The "h=h,self=self" abomination below exists because
258                 # Python didn't support nested_scopes until version 2.1
259                 w.write("%s %s;\n"%(self.name,string.join(
260                         map(lambda x,h=h,self=self:
261                                 h+"/"+x,self.children.keys()),',')))
262
263 class sitelevel(level):
264         "Site level (i.e. a leafnode) in the configuration hierarchy"
265         depth=3
266         leaf=1
267         type="site"
268         allow_properties=global_properties.copy()
269         allow_properties.update({
270          'address':sp,
271          'networks':None,
272          'peer':None,
273          'pubkey':(lambda n,v:"key %s;\n"%v),
274          'mobile':sp,
275         })
276         require_properties={
277          'dh':"Diffie-Hellman group",
278          'contact':"Site admin contact address",
279          'networks':"Networks claimed by the site",
280          'hash':"hash function",
281          'peer':"Gateway address of the site",
282          'pubkey':"RSA public key of the site",
283         }
284         def __init__(self,w):
285                 level.__init__(self,w)
286         def output_data(self,w,ind,np):
287                 self.indent(w,ind)
288                 w.write("%s {\n"%(self.name))
289                 self.indent(w,ind+2)
290                 w.write("name \"%s\";\n"%(np+self.name))
291                 self.output_props(w,ind+2)
292                 self.indent(w,ind+2)
293                 w.write("link netlink {\n");
294                 self.indent(w,ind+4)
295                 w.write("routes %s;\n"%str(self.properties["networks"]))
296                 self.indent(w,ind+4)
297                 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
298                 self.indent(w,ind+2)
299                 w.write("};\n")
300                 self.indent(w,ind)
301                 w.write("};\n")
302
303 # Levels in the configuration file
304 # (depth,properties)
305 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
306
307 # Reserved vpn/location/site names
308 reserved={'all-sites':None}
309 reserved.update(keywords)
310 reserved.update(levels)
311
312 def complain(msg):
313         "Complain about a particular input line"
314         global complaints
315         print ("%s line %d: "%(file,line))+msg
316         complaints=complaints+1
317 def moan(msg):
318         "Complain about something in general"
319         global complaints
320         print msg;
321         complaints=complaints+1
322
323 root=level(['root','root'])   # All vpns are children of this node
324 obstack=[root]
325 allow_defs=0   # Level above which new definitions are permitted
326 prefix=''
327
328 def set_property(obj,w):
329         "Set a property on a configuration node"
330         if obj.properties.has_key(w[0]):
331                 complain("%s %s already has property %s defined"%
332                         (obj.type,obj.name,w[0]))
333         else:
334                 obj.properties[w[0]]=keywords[w[0]][0](w)
335
336 def pline(i,allow_include=False):
337         "Process a configuration file line"
338         global allow_defs, obstack, root
339         w=string.split(i.rstrip('\n'))
340         if len(w)==0: return [i]
341         keyword=w[0]
342         current=obstack[len(obstack)-1]
343         if keyword=='end-definitions':
344                 allow_defs=sitelevel.depth
345                 obstack=[root]
346                 return [i]
347         if keyword=='include':
348                 if not allow_include:
349                         complain("include not permitted here")
350                         return []
351                 if len(w) != 2:
352                         complain("include requires one argument")
353                         return []
354                 newfile=os.path.join(os.path.dirname(file),w[1])
355                 return pfilepath(newfile,allow_include=allow_include)
356         if levels.has_key(keyword):
357                 # We may go up any number of levels, but only down by one
358                 newdepth=levels[keyword].depth
359                 currentdepth=len(obstack) # actually +1...
360                 if newdepth<=currentdepth:
361                         obstack=obstack[:newdepth]
362                 if newdepth>currentdepth:
363                         complain("May not go from level %d to level %d"%
364                                 (currentdepth-1,newdepth))
365                 # See if it's a new one (and whether that's permitted)
366                 # or an existing one
367                 current=obstack[len(obstack)-1]
368                 if current.children.has_key(w[1]):
369                         # Not new
370                         current=current.children[w[1]]
371                         if service and group and current.depth==2:
372                                 if group!=current.group:
373                                         complain("Incorrect group!")
374                 else:
375                         # New
376                         # Ignore depth check for now
377                         nl=levels[keyword](w)
378                         if nl.depth<allow_defs:
379                                 complain("New definitions not allowed at "
380                                         "level %d"%nl.depth)
381                                 # we risk crashing if we continue
382                                 sys.exit(1)
383                         current.children[w[1]]=nl
384                         current=nl
385                 obstack.append(current)
386                 return [i]
387         if not current.allow_properties.has_key(keyword):
388                 complain("Property %s not allowed at %s level"%
389                         (keyword,current.type))
390                 return []
391         elif current.depth == vpnlevel.depth < allow_defs:
392                 complain("Not allowed to set VPN properties here")
393                 return []
394         else:
395                 set_property(current,w)
396                 return [i]
397
398         complain("unknown keyword '%s'"%(keyword))
399
400 def pfilepath(pathname,allow_include=False):
401         f=open(pathname)
402         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
403         f.close()
404         return outlines
405
406 def pfile(name,lines,allow_include=False):
407         "Process a file"
408         global file,line
409         file=name
410         line=0
411         outlines=[]
412         for i in lines:
413                 line=line+1
414                 if (i[0]=='#'): continue
415                 outlines += pline(i,allow_include=allow_include)
416         return outlines
417
418 def outputsites(w):
419         "Output include file for secnet configuration"
420         w.write("# secnet sites file autogenerated by make-secnet-sites "
421                 +"version %s\n"%VERSION)
422         w.write("# %s\n"%time.asctime(time.localtime(time.time())))
423         w.write("# Command line: %s\n\n"%string.join(sys.argv))
424
425         # Raw VPN data section of file
426         w.write(prefix+"vpn-data {\n")
427         for i in root.children.values():
428                 i.output_data(w,2,"")
429         w.write("};\n")
430
431         # Per-VPN flattened lists
432         w.write(prefix+"vpn {\n")
433         for i in root.children.values():
434                 i.output_vpnflat(w,2,prefix+"vpn-data")
435         w.write("};\n")
436
437         # Flattened list of sites
438         w.write(prefix+"all-sites %s;\n"%string.join(
439                 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
440                         root.children.keys()),","))
441
442 # Are we being invoked from userv?
443 service=0
444 # If we are, which group does the caller want to modify?
445 group=None
446
447 line=0
448 file=None
449 complaints=0
450
451 if len(sys.argv)<2:
452         pfile("stdin",sys.stdin.readlines())
453         of=sys.stdout
454 else:
455         if sys.argv[1]=='-u':
456                 if len(sys.argv)!=6:
457                         print "Wrong number of arguments"
458                         sys.exit(1)
459                 service=1
460                 header=sys.argv[2]
461                 groupfiledir=sys.argv[3]
462                 sitesfile=sys.argv[4]
463                 group=sys.argv[5]
464                 if not os.environ.has_key("USERV_USER"):
465                         print "Environment variable USERV_USER not found"
466                         sys.exit(1)
467                 user=os.environ["USERV_USER"]
468                 # Check that group is in USERV_GROUP
469                 if not os.environ.has_key("USERV_GROUP"):
470                         print "Environment variable USERV_GROUP not found"
471                         sys.exit(1)
472                 ugs=os.environ["USERV_GROUP"]
473                 ok=0
474                 for i in string.split(ugs):
475                         if group==i: ok=1
476                 if not ok:
477                         print "caller not in group %s"%group
478                         sys.exit(1)
479                 headerinput=pfilepath(header,allow_include=True)
480                 userinput=sys.stdin.readlines()
481                 pfile("user input",userinput)
482         else:
483                 if sys.argv[1]=='-P':
484                         prefix=sys.argv[2]
485                         sys.argv[1:3]=[]
486                 if len(sys.argv)>3:
487                         print "Too many arguments"
488                         sys.exit(1)
489                 pfilepath(sys.argv[1])
490                 of=sys.stdout
491                 if len(sys.argv)>2:
492                         of=open(sys.argv[2],'w')
493
494 # Sanity check section
495 # Delete nodes where leaf=0 that have no children
496
497 def live(n):
498         "Number of leafnodes below node n"
499         if n.leaf: return 1
500         for i in n.children.keys():
501                 if live(n.children[i]): return 1
502         return 0
503 def delempty(n):
504         "Delete nodes that have no leafnode children"
505         for i in n.children.keys():
506                 delempty(n.children[i])
507                 if not live(n.children[i]):
508                         del n.children[i]
509 delempty(root)
510
511 # Check that all constraints are met (as far as I can tell
512 # restrict-nets/networks/peer are the only special cases)
513
514 def checkconstraints(n,p,ra):
515         new_p=p.copy()
516         new_p.update(n.properties)
517         for i in n.require_properties.keys():
518                 if not new_p.has_key(i):
519                         moan("%s %s is missing property %s"%
520                                 (n.type,n.name,i))
521         for i in new_p.keys():
522                 if not n.allow_properties.has_key(i):
523                         moan("%s %s has forbidden property %s"%
524                                 (n.type,n.name,i))
525         # Check address range restrictions
526         if n.properties.has_key("restrict-nets"):
527                 new_ra=ra.intersection(n.properties["restrict-nets"].set)
528         else:
529                 new_ra=ra
530         if n.properties.has_key("networks"):
531                 if not n.properties["networks"].set <= new_ra:
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)
539
540 checkconstraints(root,{},ipaddrset.complete_set())
541
542 if complaints>0:
543         if complaints==1: print "There was 1 problem."
544         else: print "There were %d problems."%(complaints)
545         sys.exit(1)
546
547 if service:
548         # Put the user's input into their group file, and rebuild the main
549         # sites file
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)
555         f.write("\n")
556         f.close()
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)
566         for i in files:
567                 if i[0]=='R':
568                         j=open(groupfiledir+"/"+i)
569                         f.write(j.read())
570                         j.close()
571         f.write("# end of sites file\n")
572         f.close()
573         os.rename(sitesfile+"-tmp",sitesfile)
574 else:
575         outputsites(of)