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