chiark / gitweb /
make-secnet-sites: Handle `pub rsa1' properties specially
[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 import argparse
64 import math
65
66 import ipaddress
67
68 # entry 0 is "near the executable", or maybe from PYTHONPATH=.,
69 # which we don't want to preempt
70 sys.path.insert(1,"/usr/local/share/secnet")
71 sys.path.insert(1,"/usr/share/secnet")
72 import ipaddrset
73 import base91
74
75 from argparseactionnoyes import ActionNoYes
76
77 VERSION="0.1.18"
78
79 max_version = 2
80
81 from sys import version_info
82 if version_info.major == 2:  # for python2
83     import codecs
84     sys.stdin = codecs.getreader('utf-8')(sys.stdin)
85     sys.stdout = codecs.getwriter('utf-8')(sys.stdout)
86     import io
87     open=lambda f,m='r': io.open(f,m,encoding='utf-8')
88
89 max={'rsa_bits':8200,'name':33,'dh_bits':8200,'algname':127}
90
91 def debugrepr(*args):
92         if debug_level > 0:
93                 print(repr(args), file=sys.stderr)
94
95 def base91s_encode(bindata):
96         return base91.encode(bindata).replace('"',"-")
97
98 def base91s_decode(string):
99         return base91.decode(string.replace("-",'"'))
100
101 class Tainted:
102         def __init__(self,s,tline=None,tfile=None):
103                 self._s=s
104                 self._ok=None
105                 self._line=line if tline is None else tline
106                 self._file=file if tfile is None else tfile
107         def __eq__(self,e):
108                 return self._s==e
109         def __ne__(self,e):
110                 # for Python2
111                 return not self.__eq__(e)
112         def __str__(self):
113                 raise RuntimeError('direct use of Tainted value')
114         def __repr__(self):
115                 return 'Tainted(%s)' % repr(self._s)
116
117         def _bad(self,what,why):
118                 assert(self._ok is not True)
119                 self._ok=False
120                 complain('bad parameter: %s: %s' % (what, why))
121                 return False
122
123         def _max_ok(self,what,maxlen):
124                 if len(self._s) > maxlen:
125                         return self._bad(what,'too long (max %d)' % maxlen)
126                 return True
127
128         def _re_ok(self,bad,what,maxlen=None):
129                 if maxlen is None: maxlen=max[what]
130                 self._max_ok(what,maxlen)
131                 if self._ok is False: return False
132                 if bad.search(self._s):
133                         #print(repr(self), file=sys.stderr)
134                         return self._bad(what,'bad syntax')
135                 return True
136
137         def _rtnval(self, is_ok, ifgood, ifbad=''):
138                 if is_ok:
139                         assert(self._ok is not False)
140                         self._ok=True
141                         return ifgood
142                 else:
143                         assert(self._ok is not True)
144                         self._ok=False
145                         return ifbad
146
147         def _rtn(self, is_ok, ifbad=''):
148                 return self._rtnval(is_ok, self._s, ifbad)
149
150         def raw(self):
151                 return self._s
152         def raw_mark_ok(self):
153                 # caller promises to throw if syntax was dangeorus
154                 return self._rtn(True)
155
156         def output(self):
157                 if self._ok is False: return ''
158                 if self._ok is True: return self._s
159                 print('%s:%d: unchecked/unknown additional data "%s"' %
160                       (self._file,self._line,self._s),
161                       file=sys.stderr)
162                 sys.exit(1)
163
164         bad_name=re.compile(r'^[^a-zA-Z]|[^-_0-9a-zA-Z]')
165         # secnet accepts _ at start of names, but we reserve that
166         bad_name_counter=0
167         def name(self,what='name'):
168                 ok=self._re_ok(Tainted.bad_name,what)
169                 return self._rtn(ok,
170                                  '_line%d_%s' % (self._line, id(self)))
171
172         def keyword(self):
173                 ok=self._s in keywords or self._s in levels
174                 if not ok:
175                         complain('unknown keyword %s' % self._s)
176                 return self._rtn(ok)
177
178         bad_hex=re.compile(r'[^0-9a-fA-F]')
179         def bignum_16(self,kind,what):
180                 maxlen=(max[kind+'_bits']+3)/4
181                 ok=self._re_ok(Tainted.bad_hex,what,maxlen)
182                 return self._rtn(ok)
183
184         bad_num=re.compile(r'[^0-9]')
185         def bignum_10(self,kind,what):
186                 maxlen=math.ceil(max[kind+'_bits'] / math.log10(2))
187                 ok=self._re_ok(Tainted.bad_num,what,maxlen)
188                 return self._rtn(ok)
189
190         def number(self,minn,maxx,what='number'):
191                 # not for bignums
192                 ok=self._re_ok(Tainted.bad_num,what,10)
193                 if ok:
194                         v=int(self._s)
195                         if v<minn or v>maxx:
196                                 ok=self._bad(what,'out of range %d..%d'
197                                              % (minn,maxx))
198                 return self._rtnval(ok,v,minn)
199
200         def hexid(self,byteslen,what):
201                 ok=self._re_ok(Tainted.bad_hex,what,byteslen*2)
202                 if ok:
203                         if len(self._s) < byteslen*2:
204                                 ok=self._bad(what,'too short')
205                 return self._rtn(ok,ifbad='00'*byteslen)
206
207         bad_host=re.compile(r'[^-\][_.:0-9a-zA-Z]')
208         # We permit _ so we can refer to special non-host domains
209         # which have A and AAAA RRs.  This is a crude check and we may
210         # still produce config files with syntactically invalid
211         # domains or addresses, but that is OK.
212         def host(self):
213                 ok=self._re_ok(Tainted.bad_host,'host/address',255)
214                 return self._rtn(ok)
215
216         bad_email=re.compile(r'[^-._0-9a-z@!$%^&*=+~/]')
217         # ^ This does not accept all valid email addresses.  That's
218         # not really possible with this input syntax.  It accepts
219         # all ones that don't require quoting anywhere in email
220         # protocols (and also accepts some invalid ones).
221         def email(self):
222                 ok=self._re_ok(Tainted.bad_email,'email address',1023)
223                 return self._rtn(ok)
224
225         bad_groupname=re.compile(r'^[^_A-Za-z]|[^-+_0-9A-Za-z]')
226         def groupname(self):
227                 ok=self._re_ok(Tainted.bad_groupname,'group name',64)
228                 return self._rtn(ok)
229
230         bad_base91=re.compile(r'[^!-~]|[\'\"\\]')
231         def base91(self,what='base91'):
232                 ok=self._re_ok(Tainted.bad_base91,what,4096)
233                 return self._rtn(ok)
234
235 class ArgActionLambda(argparse.Action):
236         def __init__(self, fn, **kwargs):
237                 self.fn=fn
238                 argparse.Action.__init__(self,**kwargs)
239         def __call__(self,ap,ns,values,option_string):
240                 self.fn(values,ns,ap,option_string)
241
242 def parse_args():
243         global service
244         global inputfile
245         global header
246         global groupfiledir
247         global sitesfile
248         global outputfile
249         global group
250         global user
251         global of
252         global prefix
253         global key_prefix
254         global debug_level
255         global output_version
256
257         ap = argparse.ArgumentParser(description='process secnet sites files')
258         ap.add_argument('--userv', '-u', action='store_true',
259                         help='userv service fragment update mode')
260         ap.add_argument('--conf-key-prefix', action=ActionNoYes,
261                         default=True,
262                  help='prefix conf file key names derived from sites data')
263         ap.add_argument('--output-version', nargs=1, type=int,
264                         help='sites file output version',
265                         default=[max_version])
266         ap.add_argument('--prefix', '-P', nargs=1,
267                         help='set prefix')
268         ap.add_argument('--debug', '-D', action='count', default=0)
269         ap.add_argument('arg',nargs=argparse.REMAINDER)
270         av = ap.parse_args()
271         debug_level = av.debug
272         debugrepr('av',av)
273         service = 1 if av.userv else 0
274         prefix = '' if av.prefix is None else av.prefix[0]
275         key_prefix = av.conf_key_prefix
276         output_version = av.output_version[0]
277         if service:
278                 if len(av.arg)!=4:
279                         print("Wrong number of arguments")
280                         sys.exit(1)
281                 (header, groupfiledir, sitesfile, group) = av.arg
282                 group = Tainted(group,0,'command line')
283                 # untrusted argument from caller
284                 if "USERV_USER" not in os.environ:
285                         print("Environment variable USERV_USER not found")
286                         sys.exit(1)
287                 user=os.environ["USERV_USER"]
288                 # Check that group is in USERV_GROUP
289                 if "USERV_GROUP" not in os.environ:
290                         print("Environment variable USERV_GROUP not found")
291                         sys.exit(1)
292                 ugs=os.environ["USERV_GROUP"]
293                 ok=0
294                 for i in ugs.split():
295                         if group==i: ok=1
296                 if not ok:
297                         print("caller not in group %s"%group)
298                         sys.exit(1)
299         else:
300                 if len(av.arg)>3:
301                         print("Too many arguments")
302                         sys.exit(1)
303                 (inputfile, outputfile) = (av.arg + [None]*2)[0:2]
304
305 parse_args()
306
307 # Classes describing possible datatypes in the configuration file
308
309 class basetype:
310         "Common protocol for configuration types."
311         def add(self,obj,w):
312                 complain("%s %s already has property %s defined"%
313                         (obj.type,obj.name,w[0].raw()))
314         def forsites(self,version,copy,fs):
315                 return copy
316
317 class conflist:
318         "A list of some kind of configuration type."
319         def __init__(self,subtype,w):
320                 self.subtype=subtype
321                 self.list=[subtype(w)]
322         def add(self,obj,w):
323                 self.list.append(self.subtype(w))
324         def __str__(self):
325                 return ', '.join(map(str, self.list))
326         def forsites(self,version,copy,fs):
327                 most_recent=self.list[len(self.list)-1]
328                 return most_recent.forsites(version,copy,fs)
329 def listof(subtype):
330         return lambda w: conflist(subtype, w)
331
332 class single_ipaddr (basetype):
333         "An IP address"
334         def __init__(self,w):
335                 self.addr=ipaddress.ip_address(w[1].raw_mark_ok())
336         def __str__(self):
337                 return '"%s"'%self.addr
338
339 class networks (basetype):
340         "A set of IP addresses specified as a list of networks"
341         def __init__(self,w):
342                 self.set=ipaddrset.IPAddressSet()
343                 for i in w[1:]:
344                         x=ipaddress.ip_network(i.raw_mark_ok(),strict=True)
345                         self.set.append([x])
346         def __str__(self):
347                 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
348
349 class dhgroup (basetype):
350         "A Diffie-Hellman group"
351         def __init__(self,w):
352                 self.mod=w[1].bignum_16('dh','dh mod')
353                 self.gen=w[2].bignum_16('dh','dh gen')
354         def __str__(self):
355                 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
356
357 class hash (basetype):
358         "A choice of hash function"
359         def __init__(self,w):
360                 hname=w[1]
361                 self.ht=hname.raw()
362                 if (self.ht!='md5' and self.ht!='sha1'):
363                         complain("unknown hash type %s"%(self.ht))
364                         self.ht=None
365                 else:
366                         hname.raw_mark_ok()
367         def __str__(self):
368                 return '%s'%(self.ht)
369
370 class email (basetype):
371         "An email address"
372         def __init__(self,w):
373                 self.addr=w[1].email()
374         def __str__(self):
375                 return '<%s>'%(self.addr)
376
377 class boolean (basetype):
378         "A boolean"
379         def __init__(self,w):
380                 v=w[1]
381                 if re.match('[TtYy1]',v.raw()):
382                         self.b=True
383                         v.raw_mark_ok()
384                 elif re.match('[FfNn0]',v.raw()):
385                         self.b=False
386                         v.raw_mark_ok()
387                 else:
388                         complain("invalid boolean value");
389         def __str__(self):
390                 return ['False','True'][self.b]
391
392 class num (basetype):
393         "A decimal number"
394         def __init__(self,w):
395                 self.n=w[1].number(0,0x7fffffff)
396         def __str__(self):
397                 return '%d'%(self.n)
398
399 class serial (basetype):
400         def __init__(self,w):
401                 self.i=w[1].hexid(4,'serial')
402         def __str__(self):
403                 return self.i
404         def forsites(self,version,copy,fs):
405                 if version < 2: return []
406                 return copy
407
408 class address (basetype):
409         "A DNS name and UDP port number"
410         def __init__(self,w):
411                 self.adr=w[1].host()
412                 self.port=w[2].number(1,65536,'port')
413         def __str__(self):
414                 return '"%s"; port %d'%(self.adr,self.port)
415
416 class pubkey (basetype):
417         "Some kind of publie key"
418         def __init__(self,w):
419                 self.a=w[1].name('algname')
420                 self.d=w[2].base91();
421         def __str__(self):
422                 return 'make-public("%s","%s")'%(self.a,self.d)
423         def forsites(self,version,xcopy,fs):
424                 if version < 2: return []
425                 return ['pub', self.a, self.d]
426
427 class rsakey (pubkey):
428         "An RSA public key"
429         def __init__(self,w):
430                 self.l=w[1].number(0,max['rsa_bits'],'rsa len')
431                 self.e=w[2].bignum_10('rsa','rsa e')
432                 self.n=w[3].bignum_10('rsa','rsa n')
433                 if len(w) >= 5: w[4].email()
434                 self.a='rsa1'
435                 self.d=base91s_encode(b'%d %s %s' %
436                                       (self.l,
437                                        self.e.encode('ascii'),
438                                        self.n.encode('ascii')))
439                 # ^ this allows us to use the pubkey.forsites()
440                 # method for output in versions>=2
441         def __str__(self):
442                 return 'rsa-public("%s","%s")'%(self.e,self.n)
443                 # this specialisation means we can generate files
444                 # compatible with old secnet executables
445         def forsites(self,version,xcopy,fs):
446                 if version < 2:
447                         return ['pubkey', str(self.l), self.e, self.n]
448                 return pubkey.forsites(self,version,xcopy,fs)
449
450 class rsakey_newfmt(rsakey):
451         "An old-style RSA public key in new-style sites format"
452         # This is its own class simply to have its own constructor.
453         def __init__(self,w):
454                 self.a=w[1].name()
455                 assert(self.a == 'rsa1')
456                 self.d=w[2].base91()
457                 try:
458                         w_inner=list(map(Tainted,
459                                         ['X-PUB-RSA1'] +
460                                         base91s_decode(self.d)
461                                         .decode('ascii')
462                                         .split(' ')))
463                 except UnicodeDecodeError:
464                         complain('rsa1 key in new format has bad base91')
465                 #print(repr(w_inner), file=sys.stderr)
466                 rsakey.__init__(self,w_inner)
467 def somepubkey(w):
468         if w[0]=='pubkey':
469                 return rsakey(w)
470         elif w[0]=='pub' and w[1]=='rsa1':
471                 return rsakey_newfmt(w)
472         elif w[0]=='pub':
473                 return pubkey(w)
474         else:
475                 assert(False)
476
477 # Possible properties of configuration nodes
478 keywords={
479  'contact':(email,"Contact address"),
480  'dh':(dhgroup,"Diffie-Hellman group"),
481  'hash':(hash,"Hash function"),
482  'key-lifetime':(num,"Maximum key lifetime (ms)"),
483  'setup-timeout':(num,"Key setup timeout (ms)"),
484  'setup-retries':(num,"Maximum key setup packet retries"),
485  'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
486  'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
487  'restrict-nets':(networks,"Allowable networks"),
488  'networks':(networks,"Claimed networks"),
489  'pub':(listof(somepubkey),"new style public site key"),
490  'pubkey':(listof(somepubkey),"RSA public site key",'pub'),
491  'peer':(single_ipaddr,"Tunnel peer IP address"),
492  'address':(address,"External contact address and port"),
493  'mobile':(boolean,"Site is mobile"),
494 }
495
496 def sp(name,value):
497         "Simply output a property - the default case"
498         return "%s %s;\n"%(name,value)
499
500 # All levels support these properties
501 global_properties={
502         'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
503         'dh':sp,
504         'hash':sp,
505         'key-lifetime':sp,
506         'setup-timeout':sp,
507         'setup-retries':sp,
508         'wait-time':sp,
509         'renegotiate-time':sp,
510         'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
511 }
512
513 class level:
514         "A level in the configuration hierarchy"
515         depth=0
516         leaf=0
517         allow_properties={}
518         require_properties={}
519         def __init__(self,w):
520                 self.type=w[0].keyword()
521                 self.name=w[1].name()
522                 self.properties={}
523                 self.children={}
524         def indent(self,w,t):
525                 w.write("                 "[:t])
526         def prop_out(self,n):
527                 return self.allow_properties[n](n,str(self.properties[n]))
528         def output_props(self,w,ind):
529                 for i in sorted(self.properties.keys()):
530                         if self.allow_properties[i]:
531                                 self.indent(w,ind)
532                                 w.write("%s"%self.prop_out(i))
533         def kname(self):
534                 return ((self.type[0].upper() if key_prefix else '')
535                         + self.name)
536         def output_data(self,w,path):
537                 ind = 2*len(path)
538                 self.indent(w,ind)
539                 w.write("%s {\n"%(self.kname()))
540                 self.output_props(w,ind+2)
541                 if self.depth==1: w.write("\n");
542                 for k in sorted(self.children.keys()):
543                         c=self.children[k]
544                         c.output_data(w,path+(c,))
545                 self.indent(w,ind)
546                 w.write("};\n")
547
548 class vpnlevel(level):
549         "VPN level in the configuration hierarchy"
550         depth=1
551         leaf=0
552         type="vpn"
553         allow_properties=global_properties.copy()
554         require_properties={
555          'contact':"VPN admin contact address"
556         }
557         def __init__(self,w):
558                 level.__init__(self,w)
559         def output_vpnflat(self,w,path):
560                 "Output flattened list of site names for this VPN"
561                 ind=2*(len(path)+1)
562                 self.indent(w,ind)
563                 w.write("%s {\n"%(self.kname()))
564                 for i in self.children.keys():
565                         self.children[i].output_vpnflat(w,path+(self,))
566                 w.write("\n")
567                 self.indent(w,ind+2)
568                 w.write("all-sites %s;\n"%
569                         ','.join(map(lambda i: i.kname(),
570                                      self.children.values())))
571                 self.indent(w,ind)
572                 w.write("};\n")
573
574 class locationlevel(level):
575         "Location level in the configuration hierarchy"
576         depth=2
577         leaf=0
578         type="location"
579         allow_properties=global_properties.copy()
580         require_properties={
581          'contact':"Location admin contact address",
582         }
583         def __init__(self,w):
584                 level.__init__(self,w)
585                 self.group=w[2].groupname()
586         def output_vpnflat(self,w,path):
587                 ind=2*(len(path)+1)
588                 self.indent(w,ind)
589                 # The "path=path,self=self" abomination below exists because
590                 # Python didn't support nested_scopes until version 2.1
591                 #
592                 #"/"+self.name+"/"+i
593                 w.write("%s %s;\n"%(self.kname(),','.join(
594                         map(lambda x,path=path,self=self:
595                             '/'.join([prefix+"vpn-data"] + list(map(
596                                     lambda i: i.kname(),
597                                     path+(self,x)))),
598                             self.children.values()))))
599
600 class sitelevel(level):
601         "Site level (i.e. a leafnode) in the configuration hierarchy"
602         depth=3
603         leaf=1
604         type="site"
605         allow_properties=global_properties.copy()
606         allow_properties.update({
607          'address':sp,
608          'networks':None,
609          'peer':None,
610          'pub':None,
611          'pubkey':None,
612          'mobile':sp,
613         })
614         require_properties={
615          'dh':"Diffie-Hellman group",
616          'contact':"Site admin contact address",
617          'networks':"Networks claimed by the site",
618          'hash':"hash function",
619          'peer':"Gateway address of the site",
620          'pub':"public key of the site",
621         }
622         def __init__(self,w):
623                 level.__init__(self,w)
624         def output_data(self,w,path):
625                 ind=2*len(path)
626                 np='/'.join(map(lambda i: i.name, path))
627                 self.indent(w,ind)
628                 w.write("%s {\n"%(self.kname()))
629                 self.indent(w,ind+2)
630                 w.write("name \"%s\";\n"%(np,))
631                 self.indent(w,ind+2)
632                 w.write("key %s;\n"%str(self.properties["pub"].list[0]))
633                 self.output_props(w,ind+2)
634                 self.indent(w,ind+2)
635                 w.write("link netlink {\n");
636                 self.indent(w,ind+4)
637                 w.write("routes %s;\n"%str(self.properties["networks"]))
638                 self.indent(w,ind+4)
639                 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
640                 self.indent(w,ind+2)
641                 w.write("};\n")
642                 self.indent(w,ind)
643                 w.write("};\n")
644
645 # Levels in the configuration file
646 # (depth,properties)
647 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
648
649 def complain(msg):
650         "Complain about a particular input line"
651         moan(("%s line %d: "%(file,line))+msg)
652 def moan(msg):
653         "Complain about something in general"
654         global complaints
655         print(msg);
656         if complaints is None: sys.exit(1)
657         complaints=complaints+1
658
659 class UntaintedRoot():
660         def __init__(self,s): self._s=s
661         def name(self): return self._s
662         def keyword(self): return self._s
663
664 root=level([UntaintedRoot(x) for x in ['root','root']])
665 # All vpns are children of this node
666 obstack=[root]
667 allow_defs=0   # Level above which new definitions are permitted
668
669 def set_property(obj,w):
670         "Set a property on a configuration node"
671         prop=w[0]
672         propname=prop.raw_mark_ok()
673         kw=keywords[propname]
674         if len(kw) >= 3: propname=kw[2] # for aliases
675         if propname in obj.properties:
676                 obj.properties[propname].add(obj,w)
677         else:
678                 obj.properties[propname]=kw[0](w)
679         return obj.properties[propname]
680
681 class FilterState:
682         def __init__(self):
683                 self.reset()
684         def reset(self):
685                 # called when we enter a new node,
686                 # in particular, at the start of each site
687                 pass
688
689 def pline(il,filterstate,allow_include=False):
690         "Process a configuration file line"
691         global allow_defs, obstack, root
692         w=il.rstrip('\n').split()
693         if len(w)==0: return ['']
694         w=list([Tainted(x) for x in w])
695         keyword=w[0]
696         current=obstack[len(obstack)-1]
697         copyout_core=lambda: ' '.join([ww.output() for ww in w])
698         indent='    '*len(obstack)
699         copyout=lambda: [indent + copyout_core() + '\n']
700         if keyword=='end-definitions':
701                 keyword.raw_mark_ok()
702                 allow_defs=sitelevel.depth
703                 obstack=[root]
704                 return copyout()
705         if keyword=='include':
706                 if not allow_include:
707                         complain("include not permitted here")
708                         return []
709                 if len(w) != 2:
710                         complain("include requires one argument")
711                         return []
712                 newfile=os.path.join(os.path.dirname(file),w[1].raw_mark_ok())
713                 # ^ user of "include" is trusted so raw_mark_ok is good
714                 return pfilepath(newfile,allow_include=allow_include)
715         if keyword.raw() in levels:
716                 # We may go up any number of levels, but only down by one
717                 newdepth=levels[keyword.raw_mark_ok()].depth
718                 currentdepth=len(obstack) # actually +1...
719                 if newdepth<=currentdepth:
720                         obstack=obstack[:newdepth]
721                 if newdepth>currentdepth:
722                         complain("May not go from level %d to level %d"%
723                                 (currentdepth-1,newdepth))
724                 # See if it's a new one (and whether that's permitted)
725                 # or an existing one
726                 current=obstack[len(obstack)-1]
727                 tname=w[1].name()
728                 if tname in current.children:
729                         # Not new
730                         current=current.children[tname]
731                         if service and group and current.depth==2:
732                                 if group!=current.group:
733                                         complain("Incorrect group!")
734                                 w[2].groupname()
735                 else:
736                         # New
737                         # Ignore depth check for now
738                         nl=levels[keyword.raw()](w)
739                         if nl.depth<allow_defs:
740                                 complain("New definitions not allowed at "
741                                         "level %d"%nl.depth)
742                                 # we risk crashing if we continue
743                                 sys.exit(1)
744                         current.children[tname]=nl
745                         current=nl
746                 filterstate.reset()
747                 obstack.append(current)
748                 return copyout()
749         if keyword.raw() not in current.allow_properties:
750                 complain("Property %s not allowed at %s level"%
751                         (keyword.raw(),current.type))
752                 return []
753         elif current.depth == vpnlevel.depth < allow_defs:
754                 complain("Not allowed to set VPN properties here")
755                 return []
756         else:
757                 prop=set_property(current,w)
758                 out=[copyout_core()]
759                 out=prop.forsites(output_version,out,filterstate)
760                 if len(out)==0: return [indent + '#', copyout_core(), '\n']
761                 return [indent + ' '.join(out) + '\n']
762
763         complain("unknown keyword '%s'"%(keyword.raw()))
764
765 def pfilepath(pathname,allow_include=False):
766         f=open(pathname)
767         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
768         f.close()
769         return outlines
770
771 def pfile(name,lines,allow_include=False):
772         "Process a file"
773         global file,line
774         file=name
775         line=0
776         outlines=[]
777         filterstate = FilterState()
778         for i in lines:
779                 line=line+1
780                 if (i[0]=='#'): continue
781                 outlines += pline(i,filterstate,allow_include=allow_include)
782         return outlines
783
784 def outputsites(w):
785         "Output include file for secnet configuration"
786         w.write("# secnet sites file autogenerated by make-secnet-sites "
787                 +"version %s\n"%VERSION)
788         w.write("# %s\n"%time.asctime(time.localtime(time.time())))
789         w.write("# Command line: %s\n\n"%' '.join(sys.argv))
790
791         # Raw VPN data section of file
792         w.write(prefix+"vpn-data {\n")
793         for i in root.children.values():
794                 i.output_data(w,(i,))
795         w.write("};\n")
796
797         # Per-VPN flattened lists
798         w.write(prefix+"vpn {\n")
799         for i in root.children.values():
800                 i.output_vpnflat(w,())
801         w.write("};\n")
802
803         # Flattened list of sites
804         w.write(prefix+"all-sites %s;\n"%",".join(
805                 map(lambda x:"%svpn/%s/all-sites"%(prefix,x.kname()),
806                         root.children.values())))
807
808 line=0
809 file=None
810 complaints=0
811
812 # Sanity check section
813 # Delete nodes where leaf=0 that have no children
814
815 def live(n):
816         "Number of leafnodes below node n"
817         if n.leaf: return 1
818         for i in n.children.keys():
819                 if live(n.children[i]): return 1
820         return 0
821 def delempty(n):
822         "Delete nodes that have no leafnode children"
823         for i in list(n.children.keys()):
824                 delempty(n.children[i])
825                 if not live(n.children[i]):
826                         del n.children[i]
827
828 # Check that all constraints are met (as far as I can tell
829 # restrict-nets/networks/peer are the only special cases)
830
831 def checkconstraints(n,p,ra):
832         new_p=p.copy()
833         new_p.update(n.properties)
834         for i in n.require_properties.keys():
835                 if i not in new_p:
836                         moan("%s %s is missing property %s"%
837                                 (n.type,n.name,i))
838         for i in new_p.keys():
839                 if i not in n.allow_properties:
840                         moan("%s %s has forbidden property %s"%
841                                 (n.type,n.name,i))
842         # Check address range restrictions
843         if "restrict-nets" in n.properties:
844                 new_ra=ra.intersection(n.properties["restrict-nets"].set)
845         else:
846                 new_ra=ra
847         if "networks" in n.properties:
848                 if not n.properties["networks"].set <= new_ra:
849                         moan("%s %s networks out of bounds"%(n.type,n.name))
850                 if "peer" in n.properties:
851                         if not n.properties["networks"].set.contains(
852                                 n.properties["peer"].addr):
853                                 moan("%s %s peer not in networks"%(n.type,n.name))
854         for i in n.children.keys():
855                 checkconstraints(n.children[i],new_p,new_ra)
856
857 if service:
858         headerinput=pfilepath(header,allow_include=True)
859         userinput=sys.stdin.readlines()
860         pfile("user input",userinput)
861 else:
862         if inputfile is None:
863                 pfile("stdin",sys.stdin.readlines())
864         else:
865                 pfilepath(inputfile)
866
867 delempty(root)
868 checkconstraints(root,{},ipaddrset.complete_set())
869
870 if complaints>0:
871         if complaints==1: print("There was 1 problem.")
872         else: print("There were %d problems."%(complaints))
873         sys.exit(1)
874 complaints=None # arranges to crash if we complain later
875
876 if service:
877         # Put the user's input into their group file, and rebuild the main
878         # sites file
879         f=open(groupfiledir+"/T"+group.groupname(),'w')
880         f.write("# Section submitted by user %s, %s\n"%
881                 (user,time.asctime(time.localtime(time.time()))))
882         f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
883         for i in userinput: f.write(i)
884         f.write("\n")
885         f.close()
886         os.rename(groupfiledir+"/T"+group.groupname(),
887                   groupfiledir+"/R"+group.groupname())
888         f=open(sitesfile+"-tmp",'w')
889         f.write("# sites file autogenerated by make-secnet-sites\n")
890         f.write("# generated %s, invoked by %s\n"%
891                 (time.asctime(time.localtime(time.time())),user))
892         f.write("# use make-secnet-sites to turn this file into a\n")
893         f.write("# valid /etc/secnet/sites.conf file\n\n")
894         for i in headerinput: f.write(i)
895         files=os.listdir(groupfiledir)
896         for i in files:
897                 if i[0]=='R':
898                         j=open(groupfiledir+"/"+i)
899                         f.write(j.read())
900                         j.close()
901         f.write("# end of sites file\n")
902         f.close()
903         os.rename(sitesfile+"-tmp",sitesfile)
904 else:
905         if outputfile is None:
906                 of=sys.stdout
907         else:
908                 tmp_outputfile=outputfile+'~tmp~'
909                 of=open(tmp_outputfile,'w')
910         outputsites(of)
911         if outputfile is not None:
912                 os.rename(tmp_outputfile,outputfile)