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