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