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