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