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