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