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