#! /usr/bin/env python
# Copyright (C) 2001-2002 Stephen Early <steve@greenend.org.uk>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

"""VPN sites file manipulation.

This program enables VPN site descriptions to be submitted for
inclusion in a central database, and allows the resulting database to
be turned into a secnet configuration file.

A database file can be turned into a secnet configuration file simply:
make-secnet-sites.py [infile [outfile]]

It would be wise to run secnet with the "--just-check-config" option
before installing the output on a live system.

The program expects to be invoked via userv to manage the database; it
relies on the USERV_USER and USERV_GROUP environment variables. The
command line arguments for this invocation are:

make-secnet-sites.py -u header-filename groupfiles-directory output-file \
  group

All but the last argument are expected to be set by userv; the 'group'
argument is provided by the user. A suitable userv configuration file
fragment is:

reset
no-disconnect-hup
no-suppress-args
cd ~/secnet/sites-test/
execute ~/secnet/make-secnet-sites.py -u vpnheader groupfiles sites

This program is part of secnet. It relies on the "ipaddr" library from
Cendio Systems AB.

"""

import string
import time
import sys
import os
import getopt

# The ipaddr library is installed as part of secnet
sys.path.append("/usr/local/share/secnet")
sys.path.append("/usr/share/secnet")
import ipaddr

VERSION="0.1.16"

# Classes describing possible datatypes in the configuration file

class single_ipaddr:
	"An IP address"
	def __init__(self,w):
		self.addr=ipaddr.ipaddr(w[1])
	def __str__(self):
		return '"%s"'%self.addr.ip_str()

class networks:
	"A set of IP addresses specified as a list of networks"
	def __init__(self,w):
		self.set=ipaddr.ip_set()
		for i in w[1:]:
			x=string.split(i,"/")
			self.set.append(ipaddr.network(x[0],x[1],
				ipaddr.DEMAND_NETWORK))
	def __str__(self):
		return string.join(map(lambda x:'"%s/%s"'%(x.ip_str(),
			x.mask.netmask_bits_str),
			self.set.as_list_of_networks()),",")

class dhgroup:
	"A Diffie-Hellman group"
	def __init__(self,w):
		self.mod=w[1]
		self.gen=w[2]
	def __str__(self):
		return 'diffie-hellman("%s","%s")'%(self.mod,self.gen)

class hash:
	"A choice of hash function"
	def __init__(self,w):
		self.ht=w[1]
		if (self.ht!='md5' and self.ht!='sha1'):
			complain("unknown hash type %s"%(self.ht))
	def __str__(self):
		return '%s'%(self.ht)

class email:
	"An email address"
	def __init__(self,w):
		self.addr=w[1]
	def __str__(self):
		return '<%s>'%(self.addr)

class num:
	"A decimal number"
	def __init__(self,w):
		self.n=string.atol(w[1])
	def __str__(self):
		return '%d'%(self.n)

class address:
	"A DNS name and UDP port number"
	def __init__(self,w):
		self.adr=w[1]
		self.port=string.atoi(w[2])
		if (self.port<1 or self.port>65535):
			complain("invalid port number")
	def __str__(self):
		return '"%s"; port %d'%(self.adr,self.port)

class rsakey:
	"An RSA public key"
	def __init__(self,w):
		self.l=string.atoi(w[1])
		self.e=w[2]
		self.n=w[3]
	def __str__(self):
		return 'rsa-public("%s","%s")'%(self.e,self.n)

# Possible properties of configuration nodes
keywords={
 'contact':(email,"Contact address"),
 'dh':(dhgroup,"Diffie-Hellman group"),
 'hash':(hash,"Hash function"),
 'key-lifetime':(num,"Maximum key lifetime (ms)"),
 'setup-timeout':(num,"Key setup timeout (ms)"),
 'setup-retries':(num,"Maximum key setup packet retries"),
 'wait-time':(num,"Time to wait after unsuccessful key setup (ms)"),
 'renegotiate-time':(num,"Time after key setup to begin renegotiation (ms)"),
 'restrict-nets':(networks,"Allowable networks"),
 'networks':(networks,"Claimed networks"),
 'pubkey':(rsakey,"RSA public site key"),
 'peer':(single_ipaddr,"Tunnel peer IP address"),
 'address':(address,"External contact address and port")
}

def sp(name,value):
	"Simply output a property - the default case"
	return "%s %s;\n"%(name,value)

# All levels support these properties
global_properties={
	'contact':(lambda name,value:"# Contact email address: %s\n"%(value)),
	'dh':sp,
	'hash':sp,
	'key-lifetime':sp,
	'setup-timeout':sp,
	'setup-retries':sp,
	'wait-time':sp,
	'renegotiate-time':sp,
	'restrict-nets':(lambda name,value:"# restrict-nets %s\n"%value)
}

class level:
	"A level in the configuration hierarchy"
	depth=0
	leaf=0
	allow_properties={}
	require_properties={}
	def __init__(self,w):
		self.name=w[1]
		self.properties={}
		self.children={}
	def indent(self,w,t):
		w.write("                 "[:t])
	def prop_out(self,n):
		return self.allow_properties[n](n,str(self.properties[n]))
	def output_props(self,w,ind):
		for i in self.properties.keys():
			if self.allow_properties[i]:
				self.indent(w,ind)
				w.write("%s"%self.prop_out(i))
	def output_data(self,w,ind,np):
		self.indent(w,ind)
		w.write("%s {\n"%(self.name))
		self.output_props(w,ind+2)
		if self.depth==1: w.write("\n");
		for c in self.children.values():
			c.output_data(w,ind+2,np+self.name+"/")
		self.indent(w,ind)
		w.write("};\n")

class vpnlevel(level):
	"VPN level in the configuration hierarchy"
	depth=1
	leaf=0
	type="vpn"
	allow_properties=global_properties.copy()
	require_properties={
	 'contact':"VPN admin contact address"
	}
	def __init__(self,w):
		level.__init__(self,w)
	def output_vpnflat(self,w,ind,h):
		"Output flattened list of site names for this VPN"
		self.indent(w,ind)
		w.write("%s {\n"%(self.name))
		for i in self.children.keys():
			self.children[i].output_vpnflat(w,ind+2,
				h+"/"+self.name+"/"+i)
		w.write("\n")
		self.indent(w,ind+2)
		w.write("all-sites %s;\n"%
			string.join(self.children.keys(),','))
		self.indent(w,ind)
		w.write("};\n")

class locationlevel(level):
	"Location level in the configuration hierarchy"
	depth=2
	leaf=0
	type="location"
	allow_properties=global_properties.copy()
	require_properties={
	 'contact':"Location admin contact address",
	}
	def __init__(self,w):
		level.__init__(self,w)
		self.group=w[2]
	def output_vpnflat(self,w,ind,h):
		self.indent(w,ind)
		# The "h=h,self=self" abomination below exists because
		# Python didn't support nested_scopes until version 2.1
		w.write("%s %s;\n"%(self.name,string.join(
			map(lambda x,h=h,self=self:
				h+"/"+x,self.children.keys()),',')))

class sitelevel(level):
	"Site level (i.e. a leafnode) in the configuration hierarchy"
	depth=3
	leaf=1
	type="site"
	allow_properties=global_properties.copy()
	allow_properties.update({
	 'address':sp,
	 'networks':None,
	 'peer':None,
	 'pubkey':(lambda n,v:"key %s;\n"%v)
	})
	require_properties={
	 'dh':"Diffie-Hellman group",
	 'contact':"Site admin contact address",
	 'address':"Site external access address",
	 'networks':"Networks claimed by the site",
	 'hash':"hash function",
	 'peer':"Gateway address of the site",
	 'pubkey':"RSA public key of the site"
	}
	def __init__(self,w):
		level.__init__(self,w)
	def output_data(self,w,ind,np):
		self.indent(w,ind)
		w.write("%s {\n"%(self.name))
		self.indent(w,ind+2)
		w.write("name \"%s\";\n"%(np+self.name))
		self.output_props(w,ind+2)
		self.indent(w,ind+2)
		w.write("link netlink {\n");
		self.indent(w,ind+4)
		w.write("routes %s;\n"%str(self.properties["networks"]))
		self.indent(w,ind+4)
		w.write("ptp-address %s;\n"%str(self.properties["peer"]))
		self.indent(w,ind+2)
		w.write("};\n")
		self.indent(w,ind)
		w.write("};\n")

# Levels in the configuration file
# (depth,properties)
levels={'vpn':vpnlevel, 'location':locationlevel, 'site':sitelevel}

# Reserved vpn/location/site names
reserved={'all-sites':None}
reserved.update(keywords)
reserved.update(levels)

def complain(msg):
	"Complain about a particular input line"
	global complaints
	print ("%s line %d: "%(file,line))+msg
	complaints=complaints+1
def moan(msg):
	"Complain about something in general"
	global complaints
	print msg;
	complaints=complaints+1

root=level(['root','root'])   # All vpns are children of this node
obstack=[root]
allow_defs=0   # Level above which new definitions are permitted

def set_property(obj,w):
	"Set a property on a configuration node"
	if obj.properties.has_key(w[0]):
		complain("%s %s already has property %s defined"%
			(obj.type,obj.name,w[0]))
	else:
		obj.properties[w[0]]=keywords[w[0]][0](w)

def pline(i):
	"Process a configuration file line"
	global allow_defs, obstack, root
	w=string.split(i)
	if len(w)==0: return
	keyword=w[0]
	current=obstack[len(obstack)-1]
	if keyword=='end-definitions':
		allow_defs=sitelevel.depth
		obstack=[root]
		return
	if levels.has_key(keyword):
		# We may go up any number of levels, but only down by one
		newdepth=levels[keyword].depth
		currentdepth=len(obstack) # actually +1...
		if newdepth<=currentdepth:
			obstack=obstack[:newdepth]
		if newdepth>currentdepth:
			complain("May not go from level %d to level %d"%
				(currentdepth-1,newdepth))
		# See if it's a new one (and whether that's permitted)
		# or an existing one
		current=obstack[len(obstack)-1]
		if current.children.has_key(w[1]):
			# Not new
			current=current.children[w[1]]
			if service and group and current.depth==2:
				if group!=current.group:
					complain("Incorrect group!")
		else:
			# New
			# Ignore depth check for now
			nl=levels[keyword](w)
			if nl.depth<allow_defs:
				complain("New definitions not allowed at "
					"level %d"%nl.depth)
			current.children[w[1]]=nl
			current=nl
		obstack.append(current)
		return
	if current.allow_properties.has_key(keyword):
		set_property(current,w)
		return
	else:
		complain("Property %s not allowed at %s level"%
			(keyword,current.type))
		return

	complain("unknown keyword '%s'"%(keyword))

def pfile(name,lines):
	"Process a file"
	global file,line
	file=name
	line=0
	for i in lines:
		line=line+1
		if (i[0]=='#'): continue
		if (i[len(i)-1]=='\n'):	i=i[:len(i)-1] # strip trailing LF
		pline(i)

def outputsites(w):
	"Output include file for secnet configuration"
	w.write("# secnet sites file autogenerated by make-secnet-sites "
		+"version %s\n"%VERSION)
	w.write("# %s\n"%time.asctime(time.localtime(time.time())))
	w.write("# Command line: %s\n\n"%string.join(sys.argv))

	# Raw VPN data section of file
	w.write("vpn-data {\n")
	for i in root.children.values():
		i.output_data(w,2,"")
	w.write("};\n")

	# Per-VPN flattened lists
	w.write("vpn {\n")
	for i in root.children.values():
		i.output_vpnflat(w,2,"vpn-data")
	w.write("};\n")

	# Flattened list of sites
	w.write("all-sites %s;\n"%string.join(map(lambda x:"vpn/%s/all-sites"%
		x,root.children.keys()),","))

# Are we being invoked from userv?
service=0
# If we are, which group does the caller want to modify?
group=None

line=0
file=None
complaints=0

if len(sys.argv)<2:
	pfile("stdin",sys.stdin.readlines())
	of=sys.stdout
else:
	if sys.argv[1]=='-u':
		if len(sys.argv)!=6:
			print "Wrong number of arguments"
			sys.exit(1)
		service=1
		header=sys.argv[2]
		groupfiledir=sys.argv[3]
		sitesfile=sys.argv[4]
		group=sys.argv[5]
		if not os.environ.has_key("USERV_USER"):
			print "Environment variable USERV_USER not found"
			sys.exit(1)
		user=os.environ["USERV_USER"]
		# Check that group is in USERV_GROUP
		if not os.environ.has_key("USERV_GROUP"):
			print "Environment variable USERV_GROUP not found"
			sys.exit(1)
		ugs=os.environ["USERV_GROUP"]
		ok=0
		for i in string.split(ugs):
			if group==i: ok=1
		if not ok:
			print "caller not in group %s"%group
			sys.exit(1)
		f=open(header)
		headerinput=f.readlines()
		f.close()
		pfile(header,headerinput)
		userinput=sys.stdin.readlines()
		pfile("user input",userinput)
	else:
		if len(sys.argv)>3:
			print "Too many arguments"
			sys.exit(1)
		f=open(sys.argv[1])
		pfile(sys.argv[1],f.readlines())
		f.close()
		of=sys.stdout
		if len(sys.argv)>2:
			of=open(sys.argv[2],'w')

# Sanity check section
# Delete nodes where leaf=0 that have no children

def live(n):
	"Number of leafnodes below node n"
	if n.leaf: return 1
	for i in n.children.keys():
		if live(n.children[i]): return 1
	return 0
def delempty(n):
	"Delete nodes that have no leafnode children"
	for i in n.children.keys():
		delempty(n.children[i])
		if not live(n.children[i]):
			del n.children[i]
delempty(root)

# Check that all constraints are met (as far as I can tell
# restrict-nets/networks/peer are the only special cases)

def checkconstraints(n,p,ra):
	new_p=p.copy()
	new_p.update(n.properties)
	for i in n.require_properties.keys():
		if not new_p.has_key(i):
			moan("%s %s is missing property %s"%
				(n.type,n.name,i))
	for i in new_p.keys():
		if not n.allow_properties.has_key(i):
			moan("%s %s has forbidden property %s"%
				(n.type,n.name,i))
	# Check address range restrictions
	if n.properties.has_key("restrict-nets"):
		new_ra=ra.intersection(n.properties["restrict-nets"].set)
	else:
		new_ra=ra
	if n.properties.has_key("networks"):
		# I'd like to do this:
		# n.properties["networks"].set.is_subset(new_ra)
		# but there isn't an is_subset() method
		# Instead we see if we intersect with the complement of new_ra
		rac=new_ra.complement()
		i=rac.intersection(n.properties["networks"].set)
		if not i.is_empty():
			moan("%s %s networks out of bounds"%(n.type,n.name))
		if n.properties.has_key("peer"):
			if not n.properties["networks"].set.contains(
				n.properties["peer"].addr):
				moan("%s %s peer not in networks"%(n.type,n.name))
	for i in n.children.keys():
		checkconstraints(n.children[i],new_p,new_ra)

checkconstraints(root,{},ipaddr.complete_set)

if complaints>0:
	if complaints==1: print "There was 1 problem."
	else: print "There were %d problems."%(complaints)
	sys.exit(1)

if service:
	# Put the user's input into their group file, and rebuild the main
	# sites file
	f=open(groupfiledir+"/T"+group,'w')
	f.write("# Section submitted by user %s, %s\n"%
		(user,time.asctime(time.localtime(time.time()))))
	f.write("# Checked by make-secnet-sites version %s\n\n"%VERSION)
	for i in userinput: f.write(i)
	f.write("\n")
	f.close()
	os.rename(groupfiledir+"/T"+group,groupfiledir+"/R"+group)
	f=open(sitesfile+"-tmp",'w')
	f.write("# sites file autogenerated by make-secnet-sites\n")
	f.write("# generated %s, invoked by %s\n"%
		(time.asctime(time.localtime(time.time())),user))
	f.write("# use make-secnet-sites to turn this file into a\n")
	f.write("# valid /etc/secnet/sites.conf file\n\n")
	for i in headerinput: f.write(i)
	files=os.listdir(groupfiledir)
	for i in files:
		if i[0]=='R':
			j=open(groupfiledir+"/"+i)
			f.write(j.read())
			j.close()
	f.write("# end of sites file\n")
	f.close()
	os.rename(sitesfile+"-tmp",sitesfile)
else:
	outputsites(of)
