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