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