chiark / gitweb /
make-secnet-sites: Rework with new `inpub' class
[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         global pubkeys_dir
257         global pubkeys_install
258
259         ap = argparse.ArgumentParser(description='process secnet sites files')
260         ap.add_argument('--userv', '-u', action='store_true',
261                         help='userv service fragment update mode')
262         ap.add_argument('--conf-key-prefix', action=ActionNoYes,
263                         default=True,
264                  help='prefix conf file key names derived from sites data')
265         ap.add_argument('--pubkeys-install', action='store_true',
266                         help='install public keys in public key directory')
267         ap.add_argument('--pubkeys-dir',  nargs=1,
268                         help='public key directory',
269                         default=['/var/lib/secnet/pubkeys'])
270         ap.add_argument('--output-version', nargs=1, type=int,
271                         help='sites file output version',
272                         default=[max_version])
273         ap.add_argument('--prefix', '-P', nargs=1,
274                         help='set prefix')
275         ap.add_argument('--debug', '-D', action='count', default=0)
276         ap.add_argument('arg',nargs=argparse.REMAINDER)
277         av = ap.parse_args()
278         debug_level = av.debug
279         debugrepr('av',av)
280         service = 1 if av.userv else 0
281         prefix = '' if av.prefix is None else av.prefix[0]
282         key_prefix = av.conf_key_prefix
283         output_version = av.output_version[0]
284         pubkeys_dir = av.pubkeys_dir[0]
285         pubkeys_install = av.pubkeys_install
286         if service:
287                 if len(av.arg)!=4:
288                         print("Wrong number of arguments")
289                         sys.exit(1)
290                 (header, groupfiledir, sitesfile, group) = av.arg
291                 group = Tainted(group,0,'command line')
292                 # untrusted argument from caller
293                 if "USERV_USER" not in os.environ:
294                         print("Environment variable USERV_USER not found")
295                         sys.exit(1)
296                 user=os.environ["USERV_USER"]
297                 # Check that group is in USERV_GROUP
298                 if "USERV_GROUP" not in os.environ:
299                         print("Environment variable USERV_GROUP not found")
300                         sys.exit(1)
301                 ugs=os.environ["USERV_GROUP"]
302                 ok=0
303                 for i in ugs.split():
304                         if group==i: ok=1
305                 if not ok:
306                         print("caller not in group %s"%group)
307                         sys.exit(1)
308         else:
309                 if len(av.arg)>3:
310                         print("Too many arguments")
311                         sys.exit(1)
312                 (inputfile, outputfile) = (av.arg + [None]*2)[0:2]
313
314 parse_args()
315
316 # Classes describing possible datatypes in the configuration file
317
318 class basetype:
319         "Common protocol for configuration types."
320         def add(self,obj,w):
321                 complain("%s %s already has property %s defined"%
322                         (obj.type,obj.name,w[0].raw()))
323         def forsites(self,version,copy,fs):
324                 return copy
325
326 class conflist:
327         "A list of some kind of configuration type."
328         def __init__(self,subtype,w):
329                 self.subtype=subtype
330                 self.list=[subtype(w)]
331         def add(self,obj,w):
332                 self.list.append(self.subtype(w))
333         def __str__(self):
334                 return ', '.join(map(str, self.list))
335         def forsites(self,version,copy,fs):
336                 most_recent=self.list[len(self.list)-1]
337                 return most_recent.forsites(version,copy,fs)
338 def listof(subtype):
339         return lambda w: conflist(subtype, w)
340
341 class single_ipaddr (basetype):
342         "An IP address"
343         def __init__(self,w):
344                 self.addr=ipaddress.ip_address(w[1].raw_mark_ok())
345         def __str__(self):
346                 return '"%s"'%self.addr
347
348 class networks (basetype):
349         "A set of IP addresses specified as a list of networks"
350         def __init__(self,w):
351                 self.set=ipaddrset.IPAddressSet()
352                 for i in w[1:]:
353                         x=ipaddress.ip_network(i.raw_mark_ok(),strict=True)
354                         self.set.append([x])
355         def __str__(self):
356                 return ",".join(map((lambda n: '"%s"'%n), self.set.networks()))
357
358 class dhgroup (basetype):
359         "A Diffie-Hellman group"
360         def __init__(self,w):
361                 self.mod=w[1].bignum_16('dh','dh mod')
362                 self.gen=w[2].bignum_16('dh','dh gen')
363         def __str__(self):
364                 return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)
365
366 class hash (basetype):
367         "A choice of hash function"
368         def __init__(self,w):
369                 hname=w[1]
370                 self.ht=hname.raw()
371                 if (self.ht!='md5' and self.ht!='sha1'):
372                         complain("unknown hash type %s"%(self.ht))
373                         self.ht=None
374                 else:
375                         hname.raw_mark_ok()
376         def __str__(self):
377                 return '%s'%(self.ht)
378
379 class email (basetype):
380         "An email address"
381         def __init__(self,w):
382                 self.addr=w[1].email()
383         def __str__(self):
384                 return '<%s>'%(self.addr)
385
386 class boolean (basetype):
387         "A boolean"
388         def __init__(self,w):
389                 v=w[1]
390                 if re.match('[TtYy1]',v.raw()):
391                         self.b=True
392                         v.raw_mark_ok()
393                 elif re.match('[FfNn0]',v.raw()):
394                         self.b=False
395                         v.raw_mark_ok()
396                 else:
397                         complain("invalid boolean value");
398         def __str__(self):
399                 return ['False','True'][self.b]
400
401 class num (basetype):
402         "A decimal number"
403         def __init__(self,w):
404                 self.n=w[1].number(0,0x7fffffff)
405         def __str__(self):
406                 return '%d'%(self.n)
407
408 class serial (basetype):
409         def __init__(self,w):
410                 self.i=w[1].hexid(4,'serial')
411         def __str__(self):
412                 return self.i
413         def forsites(self,version,copy,fs):
414                 if version < 2: return []
415                 return copy
416
417 class address (basetype):
418         "A DNS name and UDP port number"
419         def __init__(self,w):
420                 self.adr=w[1].host()
421                 self.port=w[2].number(1,65536,'port')
422         def __str__(self):
423                 return '"%s"; port %d'%(self.adr,self.port)
424
425 class inpub (basetype):
426         def forsites(self,version,xcopy,fs):
427                 return self.forpub(version,fs)
428
429 class pubkey (inpub):
430         "Some kind of publie key"
431         def __init__(self,w):
432                 self.a=w[1].name('algname')
433                 self.d=w[2].base91();
434         def __str__(self):
435                 return 'make-public("%s","%s")'%(self.a,self.d)
436         def forpub(self,version,fs):
437                 if version < 2: return []
438                 return ['pub', self.a, self.d]
439         def okforonlykey(self,version,fs):
440                 return len(self.forpub(version,fs)) != 0
441
442 class rsakey (pubkey):
443         "An RSA public key"
444         def __init__(self,w):
445                 self.l=w[1].number(0,max['rsa_bits'],'rsa len')
446                 self.e=w[2].bignum_10('rsa','rsa e')
447                 self.n=w[3].bignum_10('rsa','rsa n')
448                 if len(w) >= 5: w[4].email()
449                 self.a='rsa1'
450                 self.d=base91s_encode(b'%d %s %s' %
451                                       (self.l,
452                                        self.e.encode('ascii'),
453                                        self.n.encode('ascii')))
454                 # ^ this allows us to use the pubkey.forsites()
455                 # method for output in versions>=2
456         def __str__(self):
457                 return 'rsa-public("%s","%s")'%(self.e,self.n)
458                 # this specialisation means we can generate files
459                 # compatible with old secnet executables
460         def forpub(self,version,fs):
461                 if version < 2:
462                         if fs.pkg != '00000000': return []
463                         return ['pubkey', str(self.l), self.e, self.n]
464                 return pubkey.forpub(self,version,fs)
465
466 class rsakey_newfmt(rsakey):
467         "An old-style RSA public key in new-style sites format"
468         # This is its own class simply to have its own constructor.
469         def __init__(self,w):
470                 self.a=w[1].name()
471                 assert(self.a == 'rsa1')
472                 self.d=w[2].base91()
473                 try:
474                         w_inner=list(map(Tainted,
475                                         ['X-PUB-RSA1'] +
476                                         base91s_decode(self.d)
477                                         .decode('ascii')
478                                         .split(' ')))
479                 except UnicodeDecodeError:
480                         complain('rsa1 key in new format has bad base91')
481                 #print(repr(w_inner), file=sys.stderr)
482                 rsakey.__init__(self,w_inner)
483
484 class pubkey_group(inpub):
485         "Public key group introducer"
486         # appears in the site's list of keys mixed in with the keys
487         def __init__(self,w,fallback):
488                 self.i=w[1].hexid(4,'pkg-id')
489                 self.fallback=fallback
490         def forpub(self,version,fs):
491                 fs.pkg=self.i
492                 if version < 2: return []
493                 return ['pkgf' if self.fallback else 'pkg', self.i]
494         def okforonlykey(self,version,fs):
495                 self.forpub(version,fs)
496                 return False
497         
498 def somepubkey(w):
499         #print(repr(w), file=sys.stderr)
500         if w[0]=='pubkey':
501                 return rsakey(w)
502         elif w[0]=='pub' and w[1]=='rsa1':
503                 return rsakey_newfmt(w)
504         elif w[0]=='pub':
505                 return pubkey(w)
506         elif w[0]=='pkg':
507                 return pubkey_group(w,False)
508         elif w[0]=='pkgf':
509                 return pubkey_group(w,True)
510         else:
511                 assert(False)
512
513 # Possible properties of configuration nodes
514 keywords={
515  'contact':(email,"Contact address"),
516  'dh':(dhgroup,"Diffie-Hellman group"),
517  'hash':(hash,"Hash function"),
518  'key-lifetime':(num,"Maximum key lifetime (ms)"),
519  'setup-timeout':(num,"Key setup timeout (ms)"),
520  'setup-retries':(num,"Maximum key setup packet retries"),
521  'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
522  'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
523  'restrict-nets':(networks,"Allowable networks"),
524  'networks':(networks,"Claimed networks"),
525  'serial':(serial,"public key set serial"),
526  'pkg':(listof(somepubkey),"start of public key group",'pub'),
527  'pkgf':(listof(somepubkey),"start of fallback public key group",'pub'),
528  'pub':(listof(somepubkey),"new style public site key"),
529  'pubkey':(listof(somepubkey),"RSA public site key",'pub'),
530  'peer':(single_ipaddr,"Tunnel peer IP address"),
531  'address':(address,"External contact address and port"),
532  'mobile':(boolean,"Site is mobile"),
533 }
534
535 def sp(name,value):
536         "Simply output a property - the default case"
537         return "%s %s;\n"%(name,value)
538
539 # All levels support these properties
540 global_properties={
541         'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
542         'dh':sp,
543         'hash':sp,
544         'key-lifetime':sp,
545         'setup-timeout':sp,
546         'setup-retries':sp,
547         'wait-time':sp,
548         'renegotiate-time':sp,
549         'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value),
550 }
551
552 class level:
553         "A level in the configuration hierarchy"
554         depth=0
555         leaf=0
556         allow_properties={}
557         require_properties={}
558         def __init__(self,w):
559                 self.type=w[0].keyword()
560                 self.name=w[1].name()
561                 self.properties={}
562                 self.children={}
563         def indent(self,w,t):
564                 w.write("                 "[:t])
565         def prop_out(self,n):
566                 return self.allow_properties[n](n,str(self.properties[n]))
567         def output_props(self,w,ind):
568                 for i in sorted(self.properties.keys()):
569                         if self.allow_properties[i]:
570                                 self.indent(w,ind)
571                                 w.write("%s"%self.prop_out(i))
572         def kname(self):
573                 return ((self.type[0].upper() if key_prefix else '')
574                         + self.name)
575         def output_data(self,w,path):
576                 ind = 2*len(path)
577                 self.indent(w,ind)
578                 w.write("%s {\n"%(self.kname()))
579                 self.output_props(w,ind+2)
580                 if self.depth==1: w.write("\n");
581                 for k in sorted(self.children.keys()):
582                         c=self.children[k]
583                         c.output_data(w,path+(c,))
584                 self.indent(w,ind)
585                 w.write("};\n")
586
587 class vpnlevel(level):
588         "VPN level in the configuration hierarchy"
589         depth=1
590         leaf=0
591         type="vpn"
592         allow_properties=global_properties.copy()
593         require_properties={
594          'contact':"VPN admin contact address"
595         }
596         def __init__(self,w):
597                 level.__init__(self,w)
598         def output_vpnflat(self,w,path):
599                 "Output flattened list of site names for this VPN"
600                 ind=2*(len(path)+1)
601                 self.indent(w,ind)
602                 w.write("%s {\n"%(self.kname()))
603                 for i in self.children.keys():
604                         self.children[i].output_vpnflat(w,path+(self,))
605                 w.write("\n")
606                 self.indent(w,ind+2)
607                 w.write("all-sites %s;\n"%
608                         ','.join(map(lambda i: i.kname(),
609                                      self.children.values())))
610                 self.indent(w,ind)
611                 w.write("};\n")
612
613 class locationlevel(level):
614         "Location level in the configuration hierarchy"
615         depth=2
616         leaf=0
617         type="location"
618         allow_properties=global_properties.copy()
619         require_properties={
620          'contact':"Location admin contact address",
621         }
622         def __init__(self,w):
623                 level.__init__(self,w)
624                 self.group=w[2].groupname()
625         def output_vpnflat(self,w,path):
626                 ind=2*(len(path)+1)
627                 self.indent(w,ind)
628                 # The "path=path,self=self" abomination below exists because
629                 # Python didn't support nested_scopes until version 2.1
630                 #
631                 #"/"+self.name+"/"+i
632                 w.write("%s %s;\n"%(self.kname(),','.join(
633                         map(lambda x,path=path,self=self:
634                             '/'.join([prefix+"vpn-data"] + list(map(
635                                     lambda i: i.kname(),
636                                     path+(self,x)))),
637                             self.children.values()))))
638
639 class sitelevel(level):
640         "Site level (i.e. a leafnode) in the configuration hierarchy"
641         depth=3
642         leaf=1
643         type="site"
644         allow_properties=global_properties.copy()
645         allow_properties.update({
646          'address':sp,
647          'networks':None,
648          'peer':None,
649          'serial':None,
650          'pkg':None,
651          'pkgf':None,
652          'pub':None,
653          'pubkey':None,
654          'mobile':sp,
655         })
656         require_properties={
657          'dh':"Diffie-Hellman group",
658          'contact':"Site admin contact address",
659          'networks':"Networks claimed by the site",
660          'hash':"hash function",
661          'peer':"Gateway address of the site",
662         }
663         def mangle_name(self):
664                 return self.name.replace('/',',')
665         def pubkeys_path(self):
666                 return pubkeys_dir + '/peer.' + self.mangle_name()
667         def __init__(self,w):
668                 level.__init__(self,w)
669         def output_data(self,w,path):
670                 ind=2*len(path)
671                 np='/'.join(map(lambda i: i.name, path))
672                 self.indent(w,ind)
673                 w.write("%s {\n"%(self.kname()))
674                 self.indent(w,ind+2)
675                 w.write("name \"%s\";\n"%(np,))
676                 self.indent(w,ind+2)
677
678                 if pubkeys_install:
679                         pa=self.pubkeys_path()
680                         pw=open(pa+'~tmp','w')
681                         if 'serial' in self.properties:
682                                 pw.write('serial %s\n' %
683                                          self.properties['serial'])
684                 else:
685                         outk = []
686
687                 fs=FilterState()
688                 for k in self.properties["pub"].list:
689                         debugrepr('pubkeys ', k)
690                         if pubkeys_install:
691                                 wout=k.forpub(output_version,fs)
692                                 pw.write(' '.join(wout))
693                                 pw.write('\n')
694                         else:
695                                 if k.okforonlykey(output_version,fs):
696                                         outk.append(k)
697
698                 if pubkeys_install:
699                         pw.close()
700                         os.rename(pa+'~tmp',pa+'~update')
701                         w.write("peer-keys \"%s\";\n"%pa);
702                 else:
703                         if len(outk) == 0:
704                                 complain("site with no public key");
705                         elif len(outk) != 1:
706                                 debugrepr('outk ', outk)
707                                 complain(
708  "site with multiple public keys, without --pubkeys-install (maybe --output-version=1 would help"
709                                 )
710                         else:
711                                 w.write("key %s;\n"%str(k))
712
713                 self.output_props(w,ind+2)
714                 self.indent(w,ind+2)
715                 w.write("link netlink {\n");
716                 self.indent(w,ind+4)
717                 w.write("routes %s;\n"%str(self.properties["networks"]))
718                 self.indent(w,ind+4)
719                 w.write("ptp-address %s;\n"%str(self.properties["peer"]))
720                 self.indent(w,ind+2)
721                 w.write("};\n")
722                 self.indent(w,ind)
723                 w.write("};\n")
724
725 # Levels in the configuration file
726 # (depth,properties)
727 levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}
728
729 def complain(msg):
730         "Complain about a particular input line"
731         moan(("%s line %d: "%(file,line))+msg)
732 def moan(msg):
733         "Complain about something in general"
734         global complaints
735         print(msg);
736         if complaints is None: sys.exit(1)
737         complaints=complaints+1
738
739 class UntaintedRoot():
740         def __init__(self,s): self._s=s
741         def name(self): return self._s
742         def keyword(self): return self._s
743
744 root=level([UntaintedRoot(x) for x in ['root','root']])
745 # All vpns are children of this node
746 obstack=[root]
747 allow_defs=0   # Level above which new definitions are permitted
748
749 def set_property(obj,w):
750         "Set a property on a configuration node"
751         prop=w[0]
752         propname=prop.raw_mark_ok()
753         kw=keywords[propname]
754         if len(kw) >= 3: propname=kw[2] # for aliases
755         if propname in obj.properties:
756                 obj.properties[propname].add(obj,w)
757         else:
758                 obj.properties[propname]=kw[0](w)
759         return obj.properties[propname]
760
761 class FilterState:
762         def __init__(self):
763                 self.reset()
764         def reset(self):
765                 # called when we enter a new node,
766                 # in particular, at the start of each site
767                 self.pkg = '00000000'
768
769 def pline(il,filterstate,allow_include=False):
770         "Process a configuration file line"
771         global allow_defs, obstack, root
772         w=il.rstrip('\n').split()
773         if len(w)==0: return ['']
774         w=list([Tainted(x) for x in w])
775         keyword=w[0]
776         current=obstack[len(obstack)-1]
777         copyout_core=lambda: ' '.join([ww.output() for ww in w])
778         indent='    '*len(obstack)
779         copyout=lambda: [indent + copyout_core() + '\n']
780         if keyword=='end-definitions':
781                 keyword.raw_mark_ok()
782                 allow_defs=sitelevel.depth
783                 obstack=[root]
784                 return copyout()
785         if keyword=='include':
786                 if not allow_include:
787                         complain("include not permitted here")
788                         return []
789                 if len(w) != 2:
790                         complain("include requires one argument")
791                         return []
792                 newfile=os.path.join(os.path.dirname(file),w[1].raw_mark_ok())
793                 # ^ user of "include" is trusted so raw_mark_ok is good
794                 return pfilepath(newfile,allow_include=allow_include)
795         if keyword.raw() in levels:
796                 # We may go up any number of levels, but only down by one
797                 newdepth=levels[keyword.raw_mark_ok()].depth
798                 currentdepth=len(obstack) # actually +1...
799                 if newdepth<=currentdepth:
800                         obstack=obstack[:newdepth]
801                 if newdepth>currentdepth:
802                         complain("May not go from level %d to level %d"%
803                                 (currentdepth-1,newdepth))
804                 # See if it's a new one (and whether that's permitted)
805                 # or an existing one
806                 current=obstack[len(obstack)-1]
807                 tname=w[1].name()
808                 if tname in current.children:
809                         # Not new
810                         current=current.children[tname]
811                         if service and group and current.depth==2:
812                                 if group!=current.group:
813                                         complain("Incorrect group!")
814                                 w[2].groupname()
815                 else:
816                         # New
817                         # Ignore depth check for now
818                         nl=levels[keyword.raw()](w)
819                         if nl.depth<allow_defs:
820                                 complain("New definitions not allowed at "
821                                         "level %d"%nl.depth)
822                                 # we risk crashing if we continue
823                                 sys.exit(1)
824                         current.children[tname]=nl
825                         current=nl
826                 filterstate.reset()
827                 obstack.append(current)
828                 return copyout()
829         if keyword.raw() not in current.allow_properties:
830                 complain("Property %s not allowed at %s level"%
831                         (keyword.raw(),current.type))
832                 return []
833         elif current.depth == vpnlevel.depth < allow_defs:
834                 complain("Not allowed to set VPN properties here")
835                 return []
836         else:
837                 prop=set_property(current,w)
838                 out=[copyout_core()]
839                 out=prop.forsites(output_version,out,filterstate)
840                 if len(out)==0: return [indent + '#', copyout_core(), '\n']
841                 return [indent + ' '.join(out) + '\n']
842
843         complain("unknown keyword '%s'"%(keyword.raw()))
844
845 def pfilepath(pathname,allow_include=False):
846         f=open(pathname)
847         outlines=pfile(pathname,f.readlines(),allow_include=allow_include)
848         f.close()
849         return outlines
850
851 def pfile(name,lines,allow_include=False):
852         "Process a file"
853         global file,line
854         file=name
855         line=0
856         outlines=[]
857         filterstate = FilterState()
858         for i in lines:
859                 line=line+1
860                 if (i[0]=='#'): continue
861                 outlines += pline(i,filterstate,allow_include=allow_include)
862         return outlines
863
864 def outputsites(w):
865         "Output include file for secnet configuration"
866         w.write("# secnet sites file autogenerated by make-secnet-sites "
867                 +"version %s\n"%VERSION)
868         w.write("# %s\n"%time.asctime(time.localtime(time.time())))
869         w.write("# Command line: %s\n\n"%' '.join(sys.argv))
870
871         # Raw VPN data section of file
872         w.write(prefix+"vpn-data {\n")
873         for i in root.children.values():
874                 i.output_data(w,(i,))
875         w.write("};\n")
876
877         # Per-VPN flattened lists
878         w.write(prefix+"vpn {\n")
879         for i in root.children.values():
880                 i.output_vpnflat(w,())
881         w.write("};\n")
882
883         # Flattened list of sites
884         w.write(prefix+"all-sites %s;\n"%",".join(
885                 map(lambda x:"%svpn/%s/all-sites"%(prefix,x.kname()),
886                         root.children.values())))
887
888 line=0
889 file=None
890 complaints=0
891
892 # Sanity check section
893 # Delete nodes where leaf=0 that have no children
894
895 def live(n):
896         "Number of leafnodes below node n"
897         if n.leaf: return 1
898         for i in n.children.keys():
899                 if live(n.children[i]): return 1
900         return 0
901 def delempty(n):
902         "Delete nodes that have no leafnode children"
903         for i in list(n.children.keys()):
904                 delempty(n.children[i])
905                 if not live(n.children[i]):
906                         del n.children[i]
907
908 # Check that all constraints are met (as far as I can tell
909 # restrict-nets/networks/peer are the only special cases)
910
911 def checkconstraints(n,p,ra):
912         new_p=p.copy()
913         new_p.update(n.properties)
914         for i in n.require_properties.keys():
915                 if i not in new_p:
916                         moan("%s %s is missing property %s"%
917                                 (n.type,n.name,i))
918         for i in new_p.keys():
919                 if i not in n.allow_properties:
920                         moan("%s %s has forbidden property %s"%
921                                 (n.type,n.name,i))
922         # Check address range restrictions
923         if "restrict-nets" in n.properties:
924                 new_ra=ra.intersection(n.properties["restrict-nets"].set)
925         else:
926                 new_ra=ra
927         if "networks" in n.properties:
928                 if not n.properties["networks"].set <= new_ra:
929                         moan("%s %s networks out of bounds"%(n.type,n.name))
930                 if "peer" in n.properties:
931                         if not n.properties["networks"].set.contains(
932                                 n.properties["peer"].addr):
933                                 moan("%s %s peer not in networks"%(n.type,n.name))
934         for i in n.children.keys():
935                 checkconstraints(n.children[i],new_p,new_ra)
936
937 if service:
938         headerinput=pfilepath(header,allow_include=True)
939         userinput=sys.stdin.readlines()
940         pfile("user input",userinput)
941 else:
942         if inputfile is None:
943                 pfile("stdin",sys.stdin.readlines())
944         else:
945                 pfilepath(inputfile)
946
947 delempty(root)
948 checkconstraints(root,{},ipaddrset.complete_set())
949
950 if complaints>0:
951         if complaints==1: print("There was 1 problem.")
952         else: print("There were %d problems."%(complaints))
953         sys.exit(1)
954 complaints=None # arranges to crash if we complain later
955
956 if service:
957         # Put the user's input into their group file, and rebuild the main
958         # sites file
959         f=open(groupfiledir+"/T"+group.groupname(),'w')
960         f.write("# Section submitted by user %s, %s\n"%
961                 (user,time.asctime(time.localtime(time.time()))))
962         f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
963         for i in userinput: f.write(i)
964         f.write("\n")
965         f.close()
966         os.rename(groupfiledir+"/T"+group.groupname(),
967                   groupfiledir+"/R"+group.groupname())
968         f=open(sitesfile+"-tmp",'w')
969         f.write("# sites file autogenerated by make-secnet-sites\n")
970         f.write("# generated %s, invoked by %s\n"%
971                 (time.asctime(time.localtime(time.time())),user))
972         f.write("# use make-secnet-sites to turn this file into a\n")
973         f.write("# valid /etc/secnet/sites.conf file\n\n")
974         for i in headerinput: f.write(i)
975         files=os.listdir(groupfiledir)
976         for i in files:
977                 if i[0]=='R':
978                         j=open(groupfiledir+"/"+i)
979                         f.write(j.read())
980                         j.close()
981         f.write("# end of sites file\n")
982         f.close()
983         os.rename(sitesfile+"-tmp",sitesfile)
984 else:
985         if outputfile is None:
986                 of=sys.stdout
987         else:
988                 tmp_outputfile=outputfile+'~tmp~'
989                 of=open(tmp_outputfile,'w')
990         outputsites(of)
991         if outputfile is not None:
992                 os.rename(tmp_outputfile,outputfile)