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