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