chiark / gitweb /
make-secnet-sites: Introduce a notion of listish 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         def add(self,obj,w):
74                 complain("%s %s already has property %s defined"%
75                         (obj.type,obj.name,w[0]))
76
77 class 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))
86 def listof(subtype):
87         return lambda w: conflist(subtype, w)
88
89 class single_ipaddr (basetype):
90         "An IP address"
91         def __init__(self,w):
92                 self.addr=ipaddr.IPAddress(w[1])
93         def __str__(self):
94                 return '"%s"'%self.addr
95
96 class networks (basetype):
97         "A set of IP addresses specified as a list of networks"
98         def __init__(self,w):
99                 self.set=ipaddrset.IPAddressSet()
100                 for i in w[1:]:
101                         x=ipaddr.IPNetwork(i,strict=True)
102                         self.set.append([x])
103         def __str__(self):
104                 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
105
106 class dhgroup (basetype):
107         "A Diffie-Hellman group"
108         def __init__(self,w):
109                 self.mod=w[1]
110                 self.gen=w[2]
111         def __str__(self):
112                 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
113
114 class hash (basetype):
115         "A choice of hash function"
116         def __init__(self,w):
117                 self.ht=w[1]
118                 if (self.ht!='md5' and self.ht!='sha1'):
119                         complain("unknown hash type %s"%(self.ht))
120         def __str__(self):
121                 return '%s'%(self.ht)
122
123 class email (basetype):
124         "An email address"
125         def __init__(self,w):
126                 self.addr=w[1]
127         def __str__(self):
128                 return '<%s>'%(self.addr)
129
130 class boolean (basetype):
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
142 class num (basetype):
143         "A decimal number"
144         def __init__(self,w):
145                 self.n=string.atol(w[1])
146         def __str__(self):
147                 return '%d'%(self.n)
148
149 class address (basetype):
150         "A DNS name and UDP port number"
151         def __init__(self,w):
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")
156         def __str__(self):
157                 return '"%s"; port %d'%(self.adr,self.port)
158
159 class rsakey (basetype):
160         "An RSA public key"
161         def __init__(self,w):
162                 self.l=string.atoi(w[1])
163                 self.e=w[2]
164                 self.n=w[3]
165         def __str__(self):
166                 return 'rsa-public("%s","%s")'%(self.e,self.n)
167
168 # Possible properties of configuration nodes
169 keywords={
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"),
182  'address':(address,"External contact address and port"),
183  'mobile':(boolean,"Site is mobile"),
184 }
185
186 def 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
191 global_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,
200         'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
201 }
202
203 class 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
232 class 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
257 class 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
277 class 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,
287          'pubkey':(lambda n,v:"key %s;\n"%v),
288          'mobile':sp,
289         })
290         require_properties={
291          'dh':"Diffie-Hellman group",
292          'contact':"Site admin contact address",
293          'networks':"Networks claimed by the site",
294          'hash':"hash function",
295          'peer':"Gateway address of the site",
296          'pubkey':"RSA public key of the site",
297         }
298         def __init__(self,w):
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)
319 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
320
321 # Reserved vpn/location/site names
322 reserved={'all-sites':None}
323 reserved.update(keywords)
324 reserved.update(levels)
325
326 def complain(msg):
327         "Complain about a particular input line"
328         global complaints
329         print ("%s line %d: "%(file,line))+msg
330         complaints=complaints+1
331 def moan(msg):
332         "Complain about something in general"
333         global complaints
334         print msg;
335         complaints=complaints+1
336
337 root=level(['root','root'])   # All vpns are children of this node
338 obstack=[root]
339 allow_defs=0   # Level above which new definitions are permitted
340 prefix=''
341
342 def set_property(obj,w):
343         "Set a property on a configuration node"
344         if obj.properties.has_key(w[0]):
345                 obj.properties[w[0]].add(obj,w)
346         else:
347                 obj.properties[w[0]]=keywords[w[0]][0](w)
348
349 def pline(i,allow_include=False):
350         "Process a configuration file line"
351         global allow_defs, obstack, root
352         w=string.split(i.rstrip('\n'))
353         if len(w)==0: return [i]
354         keyword=w[0]
355         current=obstack[len(obstack)-1]
356         if keyword=='end-definitions':
357                 allow_defs=sitelevel.depth
358                 obstack=[root]
359                 return [i]
360         if keyword=='include':
361                 if not allow_include:
362                         complain("include not permitted here")
363                         return []
364                 if len(w) != 2:
365                         complain("include requires one argument")
366                         return []
367                 newfile=os.path.join(os.path.dirname(file),w[1])
368                 return pfilepath(newfile,allow_include=allow_include)
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!")
387                 else:
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)
394                                 # we risk crashing if we continue
395                                 sys.exit(1)
396                         current.children[w[1]]=nl
397                         current=nl
398                 obstack.append(current)
399                 return [i]
400         if not current.allow_properties.has_key(keyword):
401                 complain("Property %s not allowed at %s level"%
402                         (keyword,current.type))
403                 return []
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]
410
411         complain("unknown keyword '%s'"%(keyword))
412
413 def pfilepath(pathname,allow_include=False):
414         f=open(pathname)
415         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
416         f.close()
417         return outlines
418
419 def pfile(name,lines,allow_include=False):
420         "Process a file"
421         global file,line
422         file=name
423         line=0
424         outlines=[]
425         for i in lines:
426                 line=line+1
427                 if (i[0]=='#'): continue
428                 outlines += pline(i,allow_include=allow_include)
429         return outlines
430
431 def outputsites(w):
432         "Output include file for secnet configuration"
433         w.write("# secnet sites file autogenerated by make-secnet-sites "
434                 +"version %s\n"%VERSION)
435         w.write("# %s\n"%time.asctime(time.localtime(time.time())))
436         w.write("# Command line: %s\n\n"%string.join(sys.argv))
437
438         # Raw VPN data section of file
439         w.write(prefix+"vpn-data {\n")
440         for i in root.children.values():
441                 i.output_data(w,2,"")
442         w.write("};\n")
443
444         # Per-VPN flattened lists
445         w.write(prefix+"vpn {\n")
446         for i in root.children.values():
447                 i.output_vpnflat(w,2,prefix+"vpn-data")
448         w.write("};\n")
449
450         # Flattened list of sites
451         w.write(prefix+"all-sites %s;\n"%string.join(
452                 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
453                         root.children.keys()),","))
454
455 # Are we being invoked from userv?
456 service=0
457 # If we are, which group does the caller want to modify?
458 group=None
459
460 line=0
461 file=None
462 complaints=0
463
464 if len(sys.argv)<2:
465         pfile("stdin",sys.stdin.readlines())
466         of=sys.stdout
467 else:
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)
492                 headerinput=pfilepath(header,allow_include=True)
493                 userinput=sys.stdin.readlines()
494                 pfile("user input",userinput)
495         else:
496                 if sys.argv[1]=='-P':
497                         prefix=sys.argv[2]
498                         sys.argv[1:3]=[]
499                 if len(sys.argv)>3:
500                         print "Too many arguments"
501                         sys.exit(1)
502                 pfilepath(sys.argv[1])
503                 of=sys.stdout
504                 if len(sys.argv)>2:
505                         of=open(sys.argv[2],'w')
506
507 # Sanity check section
508 # Delete nodes where leaf=0 that have no children
509
510 def 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
516 def 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]
522 delempty(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
527 def 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)
541         else:
542                 new_ra=ra
543         if n.properties.has_key("networks"):
544                 if not n.properties["networks"].set <= new_ra:
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
553 checkconstraints(root,{},ipaddrset.complete_set())
554
555 if complaints>0:
556         if complaints==1: print "There was 1 problem."
557         else: print "There were %d problems."%(complaints)
558         sys.exit(1)
559
560 if service:
561         # Put the user's input into their group file, and rebuild the main
562         # sites file
563         f=open(groupfiledir+"/T"+group,'w')
564         f.write("# Section submitted by user %s, %s\n"%
565                 (user,time.asctime(time.localtime(time.time()))))
566         f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
567         for i in userinput: f.write(i)
568         f.write("\n")
569         f.close()
570         os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
571         f=open(sitesfile+"-tmp",'w')
572         f.write("# sites file autogenerated by make-secnet-sites\n")
573         f.write("# generated %s, invoked by %s\n"%
574                 (time.asctime(time.localtime(time.time())),user))
575         f.write("# use make-secnet-sites to turn this file into a\n")
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)
587 else:
588         outputsites(of)