chiark / gitweb /
make-secnet-sites: Replace string.atol with int()
[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.name=w[1]
261                 self.properties={}
262                 self.children={}
263         def indent(self,w,t):
264                 w.write("                 "[:t])
265         def prop_out(self,n):
266                 return self.allow_properties[n](n,str(self.properties[n]))
267         def output_props(self,w,ind):
268                 for i in self.properties.keys():
269                         if self.allow_properties[i]:
270                                 self.indent(w,ind)
271                                 w.write("%s"%self.prop_out(i))
272         def output_data(self,w,ind,np):
273                 self.indent(w,ind)
274                 w.write("%s {\n"%(self.name))
275                 self.output_props(w,ind+2)
276                 if self.depth==1: w.write("\n");
277                 for c in self.children.values():
278                         c.output_data(w,ind+2,np+self.name+"/")
279                 self.indent(w,ind)
280                 w.write("};\n")
281
282 class vpnlevel(level):
283         "VPN level in the configuration hierarchy"
284         depth=1
285         leaf=0
286         type="vpn"
287         allow_properties=global_properties.copy()
288         require_properties={
289          'contact':"VPN admin contact address"
290         }
291         def __init__(self,w):
292                 level.__init__(self,w)
293         def output_vpnflat(self,w,ind,h):
294                 "Output flattened list of site names for this VPN"
295                 self.indent(w,ind)
296                 w.write("%s {\n"%(self.name))
297                 for i in self.children.keys():
298                         self.children[i].output_vpnflat(w,ind+2,
299                                 h+"/"+self.name+"/"+i)
300                 w.write("\n")
301                 self.indent(w,ind+2)
302                 w.write("all-sites %s;\n"%
303                         ','.join(self.children.keys()))
304                 self.indent(w,ind)
305                 w.write("};\n")
306
307 class locationlevel(level):
308         "Location level in the configuration hierarchy"
309         depth=2
310         leaf=0
311         type="location"
312         allow_properties=global_properties.copy()
313         require_properties={
314          'contact':"Location admin contact address",
315         }
316         def __init__(self,w):
317                 level.__init__(self,w)
318                 self.group=w[2]
319         def output_vpnflat(self,w,ind,h):
320                 self.indent(w,ind)
321                 # The "h=h,self=self" abomination below exists because
322                 # Python didn't support nested_scopes until version 2.1
323                 w.write("%s %s;\n"%(self.name,','.join(
324                         map(lambda x,h=h,self=self:
325                                 h+"/"+x,self.children.keys()))))
326
327 class sitelevel(level):
328         "Site level (i.e. a leafnode) in the configuration hierarchy"
329         depth=3
330         leaf=1
331         type="site"
332         allow_properties=global_properties.copy()
333         allow_properties.update({
334          'address':sp,
335          'networks':None,
336          'peer':None,
337          'pubkey':(lambda n,v:"key %s;\n"%v),
338          'mobile':sp,
339         })
340         require_properties={
341          'dh':"Diffie-Hellman group",
342          'contact':"Site admin contact address",
343          'networks':"Networks claimed by the site",
344          'hash':"hash function",
345          'peer':"Gateway address of the site",
346          'pubkey':"RSA public key of the site",
347         }
348         def __init__(self,w):
349                 level.__init__(self,w)
350         def output_data(self,w,ind,np):
351                 self.indent(w,ind)
352                 w.write("%s {\n"%(self.name))
353                 self.indent(w,ind+2)
354                 w.write("name \"%s\";\n"%(np+self.name))
355                 self.output_props(w,ind+2)
356                 self.indent(w,ind+2)
357                 w.write("link netlink {\n");
358                 self.indent(w,ind+4)
359                 w.write("routes %s;\n"%str(self.properties["networks"]))
360                 self.indent(w,ind+4)
361                 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
362                 self.indent(w,ind+2)
363                 w.write("};\n")
364                 self.indent(w,ind)
365                 w.write("};\n")
366
367 # Levels in the configuration file
368 # (depth,properties)
369 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
370
371 # Reserved vpn/location/site names
372 reserved={'all-sites':None}
373 reserved.update(keywords)
374 reserved.update(levels)
375
376 def complain(msg):
377         "Complain about a particular input line"
378         global complaints
379         print(("%s line %d: "%(file,line))+msg)
380         complaints=complaints+1
381 def moan(msg):
382         "Complain about something in general"
383         global complaints
384         print(msg);
385         complaints=complaints+1
386
387 root=level(['root','root'])   # All vpns are children of this node
388 obstack=[root]
389 allow_defs=0   # Level above which new definitions are permitted
390 prefix=''
391
392 def set_property(obj,w):
393         "Set a property on a configuration node"
394         if w[0] in obj.properties:
395                 obj.properties[w[0]].add(obj,w)
396         else:
397                 obj.properties[w[0]]=keywords[w[0]][0](w)
398
399 def pline(i,allow_include=False):
400         "Process a configuration file line"
401         global allow_defs, obstack, root
402         w=i.rstrip('\n').split()
403         if len(w)==0: return [i]
404         keyword=w[0]
405         current=obstack[len(obstack)-1]
406         if keyword=='end-definitions':
407                 allow_defs=sitelevel.depth
408                 obstack=[root]
409                 return [i]
410         if keyword=='include':
411                 if not allow_include:
412                         complain("include not permitted here")
413                         return []
414                 if len(w) != 2:
415                         complain("include requires one argument")
416                         return []
417                 newfile=os.path.join(os.path.dirname(file),w[1])
418                 return pfilepath(newfile,allow_include=allow_include)
419         if keyword in levels:
420                 # We may go up any number of levels, but only down by one
421                 newdepth=levels[keyword].depth
422                 currentdepth=len(obstack) # actually +1...
423                 if newdepth<=currentdepth:
424                         obstack=obstack[:newdepth]
425                 if newdepth>currentdepth:
426                         complain("May not go from level %d to level %d"%
427                                 (currentdepth-1,newdepth))
428                 # See if it's a new one (and whether that's permitted)
429                 # or an existing one
430                 current=obstack[len(obstack)-1]
431                 if w[1] in current.children:
432                         # Not new
433                         current=current.children[w[1]]
434                         if service and group and current.depth==2:
435                                 if group!=current.group:
436                                         complain("Incorrect group!")
437                 else:
438                         # New
439                         # Ignore depth check for now
440                         nl=levels[keyword](w)
441                         if nl.depth<allow_defs:
442                                 complain("New definitions not allowed at "
443                                         "level %d"%nl.depth)
444                                 # we risk crashing if we continue
445                                 sys.exit(1)
446                         current.children[w[1]]=nl
447                         current=nl
448                 obstack.append(current)
449                 return [i]
450         if keyword not in current.allow_properties:
451                 complain("Property %s not allowed at %s level"%
452                         (keyword,current.type))
453                 return []
454         elif current.depth == vpnlevel.depth < allow_defs:
455                 complain("Not allowed to set VPN properties here")
456                 return []
457         else:
458                 set_property(current,w)
459                 return [i]
460
461         complain("unknown keyword '%s'"%(keyword))
462
463 def pfilepath(pathname,allow_include=False):
464         f=open(pathname)
465         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
466         f.close()
467         return outlines
468
469 def pfile(name,lines,allow_include=False):
470         "Process a file"
471         global file,line
472         file=name
473         line=0
474         outlines=[]
475         for i in lines:
476                 line=line+1
477                 if (i[0]=='#'): continue
478                 outlines += pline(i,allow_include=allow_include)
479         return outlines
480
481 def outputsites(w):
482         "Output include file for secnet configuration"
483         w.write("# secnet sites file autogenerated by make-secnet-sites "
484                 +"version %s\n"%VERSION)
485         w.write("# %s\n"%time.asctime(time.localtime(time.time())))
486         w.write("# Command line: %s\n\n"%' '.join(sys.argv))
487
488         # Raw VPN data section of file
489         w.write(prefix+"vpn-data {\n")
490         for i in root.children.values():
491                 i.output_data(w,2,"")
492         w.write("};\n")
493
494         # Per-VPN flattened lists
495         w.write(prefix+"vpn {\n")
496         for i in root.children.values():
497                 i.output_vpnflat(w,2,prefix+"vpn-data")
498         w.write("};\n")
499
500         # Flattened list of sites
501         w.write(prefix+"all-sites %s;\n"%",".join(
502                 map(lambda x:"%svpn/%s/all-sites"%(prefix,x),
503                         root.children.keys())))
504
505 line=0
506 file=None
507 complaints=0
508
509 # Sanity check section
510 # Delete nodes where leaf=0 that have no children
511
512 def live(n):
513         "Number of leafnodes below node n"
514         if n.leaf: return 1
515         for i in n.children.keys():
516                 if live(n.children[i]): return 1
517         return 0
518 def delempty(n):
519         "Delete nodes that have no leafnode children"
520         for i in n.children.keys():
521                 delempty(n.children[i])
522                 if not live(n.children[i]):
523                         del n.children[i]
524
525 # Check that all constraints are met (as far as I can tell
526 # restrict-nets/networks/peer are the only special cases)
527
528 def checkconstraints(n,p,ra):
529         new_p=p.copy()
530         new_p.update(n.properties)
531         for i in n.require_properties.keys():
532                 if i not in new_p:
533                         moan("%s %s is missing property %s"%
534                                 (n.type,n.name,i))
535         for i in new_p.keys():
536                 if i not in n.allow_properties:
537                         moan("%s %s has forbidden property %s"%
538                                 (n.type,n.name,i))
539         # Check address range restrictions
540         if "restrict-nets" in n.properties:
541                 new_ra=ra.intersection(n.properties["restrict-nets"].set)
542         else:
543                 new_ra=ra
544         if "networks" in n.properties:
545                 if not n.properties["networks"].set <= new_ra:
546                         moan("%s %s networks out of bounds"%(n.type,n.name))
547                 if "peer" in n.properties:
548                         if not n.properties["networks"].set.contains(
549                                 n.properties["peer"].addr):
550                                 moan("%s %s peer not in networks"%(n.type,n.name))
551         for i in n.children.keys():
552                 checkconstraints(n.children[i],new_p,new_ra)
553
554 if service:
555         headerinput=pfilepath(header,allow_include=True)
556         userinput=sys.stdin.readlines()
557         pfile("user input",userinput)
558 else:
559         if inputfile is None:
560                 pfile("stdin",sys.stdin.readlines())
561         else:
562                 pfilepath(inputfile)
563
564 delempty(root)
565 checkconstraints(root,{},ipaddrset.complete_set())
566
567 if complaints>0:
568         if complaints==1: print("There was 1 problem.")
569         else: print("There were %d problems."%(complaints))
570         sys.exit(1)
571
572 if service:
573         # Put the user's input into their group file, and rebuild the main
574         # sites file
575         f=open(groupfiledir+"/T"+group,'w')
576         f.write("# Section submitted by user %s, %s\n"%
577                 (user,time.asctime(time.localtime(time.time()))))
578         f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
579         for i in userinput: f.write(i)
580         f.write("\n")
581         f.close()
582         os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
583         f=open(sitesfile+"-tmp",'w')
584         f.write("# sites file autogenerated by make-secnet-sites\n")
585         f.write("# generated %s, invoked by %s\n"%
586                 (time.asctime(time.localtime(time.time())),user))
587         f.write("# use make-secnet-sites to turn this file into a\n")
588         f.write("# valid /etc/secnet/sites.conf file\n\n")
589         for i in headerinput: f.write(i)
590         files=os.listdir(groupfiledir)
591         for i in files:
592                 if i[0]=='R':
593                         j=open(groupfiledir+"/"+i)
594                         f.write(j.read())
595                         j.close()
596         f.write("# end of sites file\n")
597         f.close()
598         os.rename(sitesfile+"-tmp",sitesfile)
599 else:
600         outputsites(of)