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