chiark / gitweb /
site: transport peers: Formalise interface to transport peers
[secnet.git] / make-secnet-sites
1 #! /usr/bin/env python
2 # Copyright (C) 2001-2002 Stephen Early <steve@greenend.org.uk>
3 #
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.
8
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.
13
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
17
18 """VPN sites file manipulation.
19
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.
23
24 A database file can be turned into a secnet configuration file simply:
25 make-secnet-sites.py [infile [outfile]]
26
27 It would be wise to run secnet with the "--just-check-config" option
28 before installing the output on a live system.
29
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:
33
34 make-secnet-sites.py -u header-filename groupfiles-directory output-file \
35   group
36
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
39 fragment is:
40
41 reset
42 no-disconnect-hup
43 no-suppress-args
44 cd ~/secnet/sites-test/
45 execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites
46
47 This program is part of secnet. It relies on the "ipaddr" library from
48 Cendio Systems AB.
49
50 """
51
52 import string
53 import time
54 import sys
55 import os
56 import getopt
57 import re
58
59 # The ipaddr library is installed as part of secnet
60 sys.path.insert(0,"/usr/local/share/secnet")
61 sys.path.insert(0,"/usr/share/secnet")
62 import ipaddr
63
64 VERSION="0.1.18"
65
66 # Classes describing possible datatypes in the configuration file
67
68 class single_ipaddr:
69         "An IP address"
70         def __init__(self,w):
71                 self.addr=ipaddr.ipaddr(w[1])
72         def __str__(self):
73                 return '"%s"'%self.addr.ip_str()
74
75 class networks:
76         "A set of IP addresses specified as a list of networks"
77         def __init__(self,w):
78                 self.set=ipaddr.ip_set()
79                 for i in w[1:]:
80                         x=string.split(i,"/")
81                         self.set.append(ipaddr.network(x[0],x[1],
82                                 ipaddr.DEMAND_NETWORK))
83         def __str__(self):
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()),",")
87
88 class dhgroup:
89         "A Diffie-Hellman group"
90         def __init__(self,w):
91                 self.mod=w[1]
92                 self.gen=w[2]
93         def __str__(self):
94                 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
95
96 class hash:
97         "A choice of hash function"
98         def __init__(self,w):
99                 self.ht=w[1]
100                 if (self.ht!='md5' and self.ht!='sha1'):
101                         complain("unknown hash type %s"%(self.ht))
102         def __str__(self):
103                 return '%s'%(self.ht)
104
105 class email:
106         "An email address"
107         def __init__(self,w):
108                 self.addr=w[1]
109         def __str__(self):
110                 return '<%s>'%(self.addr)
111
112 class 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
124 class num:
125         "A decimal number"
126         def __init__(self,w):
127                 self.n=string.atol(w[1])
128         def __str__(self):
129                 return '%d'%(self.n)
130
131 class address:
132         "A DNS name and UDP port number"
133         def __init__(self,w):
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")
138         def __str__(self):
139                 return '"%s"; port %d'%(self.adr,self.port)
140
141 class rsakey:
142         "An RSA public key"
143         def __init__(self,w):
144                 self.l=string.atoi(w[1])
145                 self.e=w[2]
146                 self.n=w[3]
147         def __str__(self):
148                 return 'rsa-public("%s","%s")'%(self.e,self.n)
149
150 # Possible properties of configuration nodes
151 keywords={
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"),
166 }
167
168 def 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
173 global_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,
182         'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
183 }
184
185 class 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
214 class 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
239 class 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
259 class 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,
269          'pubkey':(lambda n,v:"key %s;\n"%v),
270          'address':(lambda n,v:"address %s;\n"%v),
271          'mobile':sp,
272         })
273         require_properties={
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",
280         }
281         def __init__(self,w):
282                 level.__init__(self,w)
283         def output_data(self,w,ind,np):
284                 self.indent(w,ind)
285                 w.write("%s {\n"%(self.name))
286                 self.indent(w,ind+2)
287                 w.write("name \"%s\";\n"%(np+self.name))
288                 self.output_props(w,ind+2)
289                 self.indent(w,ind+2)
290                 w.write("link netlink {\n");
291                 self.indent(w,ind+4)
292                 w.write("routes %s;\n"%str(self.properties["networks"]))
293                 self.indent(w,ind+4)
294                 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
295                 self.indent(w,ind+2)
296                 w.write("};\n")
297                 self.indent(w,ind)
298                 w.write("};\n")
299
300 # Levels in the configuration file
301 # (depth,properties)
302 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
303
304 # Reserved vpn/location/site names
305 reserved={'all-sites':None}
306 reserved.update(keywords)
307 reserved.update(levels)
308
309 def complain(msg):
310         "Complain about a particular input line"
311         global complaints
312         print ("%s line %d: "%(file,line))+msg
313         complaints=complaints+1
314 def moan(msg):
315         "Complain about something in general"
316         global complaints
317         print msg;
318         complaints=complaints+1
319
320 root=level(['root','root'])   # All vpns are children of this node
321 obstack=[root]
322 allow_defs=0   # Level above which new definitions are permitted
323 prefix=''
324
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]))
330         else:
331                 obj.properties[w[0]]=keywords[w[0]][0](w)
332
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]
338         keyword=w[0]
339         current=obstack[len(obstack)-1]
340         if keyword=='end-definitions':
341                 allow_defs=sitelevel.depth
342                 obstack=[root]
343                 return [i]
344         if keyword=='include':
345                 if not allow_include:
346                         complain("include not permitted here")
347                         return []
348                 if len(w) != 2:
349                         complain("include requires one argument")
350                         return []
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)
363                 # or an existing one
364                 current=obstack[len(obstack)-1]
365                 if current.children.has_key(w[1]):
366                         # Not new
367                         current=current.children[w[1]]
368                         if service and group and current.depth==2:
369                                 if group!=current.group:
370                                         complain("Incorrect group!")
371                 else:
372                         # New
373                         # Ignore depth check for now
374                         nl=levels[keyword](w)
375                         if nl.depth<allow_defs:
376                                 complain("New definitions not allowed at "
377                                         "level %d"%nl.depth)
378                                 # we risk crashing if we continue
379                                 sys.exit(1)
380                         current.children[w[1]]=nl
381                         current=nl
382                 obstack.append(current)
383                 return [i]
384         if current.allow_properties.has_key(keyword):
385                 set_property(current,w)
386                 return [i]
387         else:
388                 complain("Property %s not allowed at %s level"%
389                         (keyword,current.type))
390                 return []
391
392         complain("unknown keyword '%s'"%(keyword))
393
394 def pfilepath(pathname,allow_include=False):
395         f=open(pathname)
396         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
397         f.close()
398         return outlines
399
400 def pfile(name,lines,allow_include=False):
401         "Process a file"
402         global file,line
403         file=name
404         line=0
405         outlines=[]
406         for i in lines:
407                 line=line+1
408                 if (i[0]=='#'): continue
409                 outlines += pline(i,allow_include=allow_include)
410         return outlines
411
412 def outputsites(w):
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))
418
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,"")
423         w.write("};\n")
424
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")
429         w.write("};\n")
430
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()),","))
435
436 # Are we being invoked from userv?
437 service=0
438 # If we are, which group does the caller want to modify?
439 group=None
440
441 line=0
442 file=None
443 complaints=0
444
445 if len(sys.argv)<2:
446         pfile("stdin",sys.stdin.readlines())
447         of=sys.stdout
448 else:
449         if sys.argv[1]=='-u':
450                 if len(sys.argv)!=6:
451                         print "Wrong number of arguments"
452                         sys.exit(1)
453                 service=1
454                 header=sys.argv[2]
455                 groupfiledir=sys.argv[3]
456                 sitesfile=sys.argv[4]
457                 group=sys.argv[5]
458                 if not os.environ.has_key("USERV_USER"):
459                         print "Environment variable USERV_USER not found"
460                         sys.exit(1)
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"
465                         sys.exit(1)
466                 ugs=os.environ["USERV_GROUP"]
467                 ok=0
468                 for i in string.split(ugs):
469                         if group==i: ok=1
470                 if not ok:
471                         print "caller not in group %s"%group
472                         sys.exit(1)
473                 headerinput=pfilepath(header,allow_include=True)
474                 userinput=sys.stdin.readlines()
475                 pfile("user input",userinput)
476         else:
477                 if sys.argv[1]=='-P':
478                         prefix=sys.argv[2]
479                         sys.argv[1:3]=[]
480                 if len(sys.argv)>3:
481                         print "Too many arguments"
482                         sys.exit(1)
483                 pfilepath(sys.argv[1])
484                 of=sys.stdout
485                 if len(sys.argv)>2:
486                         of=open(sys.argv[2],'w')
487
488 # Sanity check section
489 # Delete nodes where leaf=0 that have no children
490
491 def live(n):
492         "Number of leafnodes below node n"
493         if n.leaf: return 1
494         for i in n.children.keys():
495                 if live(n.children[i]): return 1
496         return 0
497 def delempty(n):
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]):
502                         del n.children[i]
503 delempty(root)
504
505 # Check that all constraints are met (as far as I can tell
506 # restrict-nets/networks/peer are the only special cases)
507
508 def checkconstraints(n,p,ra):
509         new_p=p.copy()
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"%
514                                 (n.type,n.name,i))
515         for i in new_p.keys():
516                 if not n.allow_properties.has_key(i):
517                         moan("%s %s has forbidden property %s"%
518                                 (n.type,n.name,i))
519         # Check address range restrictions
520         if n.properties.has_key("restrict-nets"):
521                 new_ra=ra.intersection(n.properties["restrict-nets"].set)
522         else:
523                 new_ra=ra
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)
531                 if not i.is_empty():
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,{},ipaddr.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)