chiark / gitweb /
Import release 0.1.3
[secnet.git] / make-secnet-sites.py
1 #! /usr/bin/env python
2 # Copyright (C) 2001 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/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 ipaddr
57
58 VERSION="0.1.3"
59
60 class vpn:
61         def __init__(self,name):
62                 self.name=name
63                 self.allow_defs=0
64                 self.locations={}
65                 self.defs={}
66
67 class location:
68         def __init__(self,name,vpn):
69                 self.group=None
70                 self.name=name
71                 self.allow_defs=1
72                 self.vpn=vpn
73                 self.sites={}
74                 self.defs={}
75
76 class site:
77         def __init__(self,name,location):
78                 self.name=name
79                 self.allow_defs=1
80                 self.location=location
81                 self.defs={}
82
83 class nets:
84         def __init__(self,w):
85                 self.w=w
86                 self.set=ipaddr.ip_set()
87                 for i in w[1:]:
88                         x=string.split(i,"/")
89                         self.set.append(ipaddr.network(x[0],x[1],
90                                 ipaddr.DEMAND_NETWORK))
91         def subsetof(self,s):
92                 # I'd like to do this:
93                 # return self.set.is_subset(s)
94                 # but there isn't an is_subset() method
95                 # Instead we see if we intersect with the complement of s
96                 sc=s.set.complement()
97                 i=sc.intersection(self.set)
98                 return i.is_empty()
99         def out(self):
100                 rn=''
101                 if (self.w[0]=='restrict-nets'): rn='# '
102                 return '%s%s %s;'%(rn,self.w[0],
103                         string.join(map(lambda x:'"%s/%s"'%(x.ip_str(),
104                                 x.mask.netmask_bits_str),
105                                 self.set.as_list_of_networks()),","))
106
107 class dhgroup:
108         def __init__(self,w):
109                 self.w=w
110         def out(self):
111                 return 'dh diffie-hellman("%s","%s");'%(self.w[1],self.w[2])
112
113 class hash:
114         def __init__(self,w):
115                 self.w=w
116                 if (w[1]!='md5' and w[1]!='sha1'):
117                         complain("unknown hash type %s"%(w[1]))
118         def out(self):
119                 return 'hash %s;'%(self.w[1])
120
121 class email:
122         def __init__(self,w):
123                 self.w=w
124         def out(self):
125                 return '# Contact email address: <%s>'%(self.w[1])
126
127 class num:
128         def __init__(self,w):
129                 self.w=w
130         def out(self):
131                 return '%s %s;'%(self.w[0],self.w[1])
132
133 class address:
134         def __init__(self,w):
135                 self.w=w
136         def out(self):
137                 return 'address "%s"; port %s;'%(self.w[1],self.w[2])
138
139 class rsakey:
140         def __init__(self,w):
141                 self.w=w
142         def out(self):
143                 return 'key rsa-public("%s","%s");'%(self.w[2],self.w[3])
144
145 class mobileoption:
146         def __init__(self,w):
147                 self.w=w
148         def out(self):
149                 return 'netlink-options "soft";'
150
151 def complain(msg):
152         global complaints
153         print ("%s line %d: "%(file,line))+msg
154         complaints=complaints+1
155 def moan(msg):
156         global complaints
157         print msg;
158         complaints=complaints+1
159
160 # We don't allow redefinition of properties (because that would allow things
161 # like restrict-nets to be redefined, which would be bad)
162 def set(obj,defs,w):
163         if (obj.allow_defs | allow_defs):
164                 if (obj.defs.has_key(w[0])):
165                         complain("%s is already defined"%(w[0]))
166                 else:
167                         t=defs[w[0]]
168                         obj.defs[w[0]]=t(w)
169
170 # Process a line of configuration file
171 def pline(i):
172         global allow_defs, group, current_vpn, current_location, current_object
173         w=string.split(i)
174         if len(w)==0: return
175         keyword=w[0]
176         if keyword=='end-definitions':
177                 allow_defs=0
178                 current_vpn=None
179                 current_location=None
180                 current_object=None
181                 return
182         if keyword=='vpn':
183                 if vpns.has_key(w[1]):
184                         current_vpn=vpns[w[1]]
185                         current_object=current_vpn
186                 else:
187                         if allow_defs:
188                                 current_vpn=vpn(w[1])
189                                 vpns[w[1]]=current_vpn
190                                 current_object=current_vpn
191                         else:
192                                 complain("no new VPN definitions allowed")
193                 return
194         if (current_vpn==None):
195                 complain("no VPN defined yet")
196                 return
197         # Keywords that can apply at all levels
198         if mldefs.has_key(w[0]):
199                 set(current_object,mldefs,w)
200                 return
201         if keyword=='location':
202                 if (current_vpn.locations.has_key(w[1])):
203                         current_location=current_vpn.locations[w[1]]
204                         current_object=current_location
205                         if (group and not allow_defs and 
206                                 current_location.group!=group):
207                                 complain(("must be group %s to access "+
208                                         "location %s")%(current_location.group,
209                                         w[1]))
210                 else:
211                         if allow_defs:
212                                 if reserved.has_key(w[1]):
213                                         complain("reserved location name")
214                                         return
215                                 current_location=location(w[1],current_vpn)
216                                 current_vpn.locations[w[1]]=current_location
217                                 current_object=current_location
218                         else:
219                                 complain("no new location definitions allowed")
220                 return
221         if (current_location==None):
222                 complain("no locations defined yet")
223                 return
224         if keyword=='group':
225                 current_location.group=w[1]
226                 return
227         if keyword=='site':
228                 if (current_location.sites.has_key(w[1])):
229                         current_object=current_location.sites[w[1]]
230                 else:
231                         if reserved.has_key(w[1]):
232                                 complain("reserved site name")
233                                 return
234                         current_object=site(w[1],current_location)
235                         current_location.sites[w[1]]=current_object
236                 return
237         if keyword=='endsite':
238                 if isinstance(current_object,site):
239                         current_object=current_object.location
240                 else:
241                         complain("not currently defining a site")
242                 return
243         # Keywords that can only apply to sites
244         if isinstance(current_object,site):
245                 if sitedefs.has_key(w[0]):
246                         set(current_object,sitedefs,w)
247                         return
248         else:
249                 if sitedefs.has_key(w[0]):
250                         complain("keyword '%s' can only be used in the "
251                                 "context of a site definition"%(w[0]))
252                         return
253         complain("unknown keyword '%s'"%(w[0]))
254
255 def pfile(name,lines):
256         global file,line
257         file=name
258         line=0
259         for i in lines:
260                 line=line+1
261                 if (i[0]=='#'): continue
262                 if (i[len(i)-1]=='\n'): i=i[:len(i)-1] # strip trailing LF
263                 pline(i)
264
265 def outputsites(w):
266         w.write("# secnet sites file autogenerated by make-secnet-sites.py "
267                 +"version %s\n"%VERSION)
268         w.write("# %s\n\n"%time.asctime(time.localtime(time.time())))
269
270         # Raw VPN data section of file
271         w.write("vpn-data {\n")
272         for i in vpns.values():
273                 w.write("  %s {\n"%i.name)
274                 for d in i.defs.values():
275                         w.write("    %s\n"%d.out())
276                 w.write("\n")
277                 for l in i.locations.values():
278                         w.write("    %s {\n"%l.name)
279                         for d in l.defs.values():
280                                 w.write("      %s\n"%d.out())
281                         for s in l.sites.values():
282                                 w.write("      %s {\n"%s.name)
283                                 w.write('        name "%s/%s/%s";\n'%
284                                         (i.name,l.name,s.name))
285                                 for d in s.defs.values():
286                                         w.write("        %s\n"%d.out())
287                                 w.write("      };\n")
288                         w.write("    };\n")
289                 w.write("  };\n")
290         w.write("};\n")
291
292         # Per-VPN flattened lists
293         w.write("vpn {\n")
294         for i in vpns.values():
295                 w.write("  %s {\n"%(i.name))
296                 for l in i.locations.values():
297                         slist=map(lambda x:"vpn-data/%s/%s/%s"%
298                                 (i.name,l.name,x.name),
299                                 l.sites.values())
300                         w.write("    %s %s;\n"%(l.name,string.join(slist,",")))
301                 w.write("\n    all-sites %s;\n"%
302                         string.join(i.locations.keys(),","))
303                 w.write("  };\n")
304         w.write("};\n")
305
306         # Flattened list of sites
307         w.write("all-sites %s;\n"%string.join(map(lambda x:"vpn/%s/all-sites"%
308                 x,vpns.keys()),","))
309
310 # Are we being invoked from userv?
311 service=0
312 # If we are, which group does the caller want to modify?
313 group=None
314
315 vpns={}
316 allow_defs=1
317 current_vpn=None
318 current_location=None
319 current_object=None
320
321 line=0
322 file=None
323 complaints=0
324
325 # Things that can be defined at any level
326 mldefs={
327         'dh':dhgroup,
328         'hash':hash,
329         'contact':email,
330         'key-lifetime':num,
331         'setup-retries':num,
332         'setup-timeout':num,
333         'wait-time':num,
334         'renegotiate-time':num,
335         'restrict-nets':nets
336         }
337
338 # Things that can only be defined for sites
339 sitedefs={
340         'address':address,
341         'networks':nets,
342         'pubkey':rsakey,
343         'mobile':mobileoption
344         }
345
346 # Reserved vpn/location/site names
347 reserved={'all-sites':None}
348 reserved.update(mldefs)
349 reserved.update(sitedefs)
350
351 # Each site must have the following defined at some level:
352 required={
353         'dh':"Diffie-Hellman group",
354         'networks':"network list",
355         'pubkey':"public key",
356         'hash':"hash function"
357         }
358
359 if len(sys.argv)<2:
360         pfile("stdin",sys.stdin.readlines())
361         of=sys.stdout
362 else:
363         if sys.argv[1]=='-u':
364                 if len(sys.argv)!=6:
365                         print "Wrong number of arguments"
366                         sys.exit(1)
367                 service=1
368                 header=sys.argv[2]
369                 groupfiledir=sys.argv[3]
370                 sitesfile=sys.argv[4]
371                 group=sys.argv[5]
372                 if not os.environ.has_key("USERV_USER"):
373                         print "Environment variable USERV_USER not found"
374                         sys.exit(1)
375                 user=os.environ["USERV_USER"]
376                 # Check that group is in USERV_GROUP
377                 if not os.environ.has_key("USERV_GROUP"):
378                         print "Environment variable USERV_GROUP not found"
379                         sys.exit(1)
380                 ugs=os.environ["USERV_GROUP"]
381                 ok=0
382                 for i in string.split(ugs):
383                         if group==i: ok=1
384                 if not ok:
385                         print "caller not in group %s"%group
386                         sys.exit(1)
387                 f=open(header)
388                 pfile(header,f.readlines())
389                 f.close()
390                 userinput=sys.stdin.readlines()
391                 pfile("user input",userinput)
392         else:
393                 if len(sys.argv)>3:
394                         print "Too many arguments"
395                         sys.exit(1)
396                 f=open(sys.argv[1])
397                 pfile(sys.argv[1],f.readlines())
398                 f.close()
399                 of=sys.stdout
400                 if len(sys.argv)>2:
401                         of=open(sys.argv[2],'w')
402
403 # Sanity check section
404
405 # Delete locations that have no sites defined
406 for i in vpns.values():
407         for l in i.locations.keys():
408                 if (len(i.locations[l].sites.values())==0):
409                         del i.locations[l]
410
411 # Delete VPNs that have no locations with sites defined
412 for i in vpns.keys():
413         if (len(vpns[i].locations.values())==0):
414                 del vpns[i]
415
416 # Check all sites
417 for i in vpns.values():
418         if i.defs.has_key('restrict-nets'):
419                 vr=i.defs['restrict-nets']
420         else:
421                 vr=None
422         for l in i.locations.values():
423                 if l.defs.has_key('restrict-nets'):
424                         lr=l.defs['restrict-nets']
425                         if (not lr.subsetof(vr)):
426                                 moan("location %s/%s restrict-nets is invalid"%
427                                         (i.name,l.name))
428                 else:
429                         lr=vr
430                 for s in l.sites.values():
431                         sn="%s/%s/%s"%(i.name,l.name,s.name)
432                         for r in required.keys():
433                                 if (not (s.defs.has_key(r) or
434                                         l.defs.has_key(r) or
435                                         i.defs.has_key(r))):
436                                         moan("site %s missing parameter %s"%
437                                                 (sn,r))
438                         if s.defs.has_key('restrict-nets'):
439                                 sr=s.defs['restrict-nets']
440                                 if (not sr.subsetof(lr)):
441                                         moan("site %s restrict-nets not valid"%
442                                                 sn)
443                         else:
444                                 sr=lr
445                         if not s.defs.has_key('networks'): continue
446                         nets=s.defs['networks']
447                         if (not nets.subsetof(sr)):
448                                 moan("site %s networks exceed restriction"%sn)
449
450
451 if complaints>0:
452         if complaints==1: print "There was 1 problem."
453         else: print "There were %d problems."%(complaints)
454         sys.exit(1)
455
456 if service:
457         # Put the user's input into their group file, and rebuild the main
458         # sites file
459         f=open(groupfiledir+"-tmp/"+group,'w')
460         f.write("# Section submitted by user %s, %s\n"%
461                 (user,time.asctime(time.localtime(time.time()))))
462         f.write("# Checked by make-secnet-sites.py version %s\n\n"%VERSION)
463         for i in userinput: f.write(i)
464         f.write("\n")
465         f.close()
466         os.rename(groupfiledir+"-tmp/"+group,groupfiledir+"/"+group)
467         # XXX rebuild main sites file!
468 else:
469         outputsites(of)