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