#!/usr/bin/env python
# kidsafe child safe proxy server using squid
# see http://www.penguintutor.com/kidsafe
# kidsafe.py - squid v3 authethentication helper application
# Copyright Stewart Watkiss 2012

# This file is part of kidsafe.
# 
# kidsafe 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 3 of the License, or
# (at your option) any later version.
# 
# kidsafe 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 kidsafe.  If not, see <http://www.gnu.org/licenses/>.


import sys, time, datetime, os

# These are global variables so can be accessed elsewhere
# These are not normally updated during running
# Only loglevel needs to be global, but others kept together at start of file

# Set loglevel to amount of logging required
# 0 = no logging, 1 = critical only, 2 = significant (warning), 3 = disable all logging of accepts / denies, 4 = log entries where log=yes is set on rule, 5 = include denies, 6 = include accept, 7 = include session tests, 8 = detailed session log, 9 = debug, 10 = full debug
# Normally this should be level 4 (or level 5 if want to log all blocked sites)
# above 6 not recommended for normal use
# 3 is same as 2 at the moment
loglevel = 6
#loglevel = 10
session_file = "/opt/kidsafe/kidsafe.session"
rules_file = "/opt/kidsafe/kidsafe.rules"
log_file = "/var/log/squid3/kidsafe.log"


def main ():

	global loglevel
	global rules_file
	global session_file

	# store time when files were last modified - if this is updated then we reload the file
	r_mtime = os.path.getmtime(rules_file)
	s_mtime = os.path.getmtime(session_file)

	if (loglevel >= 2):
		logEntry (2, "kidsafe starting")
	

	# Session list
	# holds whether user is logged in and at what level access they have
	sessions = loadSessions(session_file); 

	# List to hold whitelist
	# each entry in the whitelist is a list containing
	rules = loadRules(rules_file);
	

	while (1):
		# read entry from stdin - src, dst, dstport
		inline = sys.stdin.readline()
		# only use src & dst; dstport is for logging purposes
		# path is included, but not used. In future this can be used for additional keyword checks against url
		(src, dst, dstport, path) = inline.split()
		
		if (loglevel >= 10):
			logEntry (10, "New proxy request "+inline)
		# current time - we use this later to check for expired entries / sessions
		timenow = int(time.time());
	
		# convert dst to lowercase (rest should be IP addresses)
		dst = dst.lower()
		
		# check if sessions file updated (mtime) to see if we need to reload
		if (loglevel >= 10):
			logEntry (10, "last loaded mtime "+str(s_mtime))
			logEntry (10, "new mtime "+str(os.path.getmtime(session_file)))
		if (s_mtime != os.path.getmtime(session_file)):
			sessions = loadSessions(session_file)
			s_mtime = os.path.getmtime(session_file)

		# check if rules file updated (mtime) to see if we need to reload
		if (loglevel >= 10):
			logEntry (10, "last loaded mtime "+str(r_mtime))
			logEntry (10, "new mtime "+str(os.path.getmtime(rules_file)))
		if (r_mtime != os.path.getmtime(rules_file)):
			rules = loadRules(rules_file)
			r_mtime = os.path.getmtime(rules_file)

		# reset authorisation level
		authlevel = 0

		# check to see if the user is logged on and get permission level
		for sessionentry in sessions:
			# check time not expired (when we loaded we checked, but time could have expired since)
			if (sessionentry[3]!='*' and sessionentry[3]!= '0' and int(sessionentry[3]) < timenow):
				# may not be relevant, but we include in level 8
				if (loglevel >= 8):
					logEntry (8, "Expired session "+str(sessionentry))
				# expired to move to next
				continue
			if (checkAddress (src,sessionentry[0])):
				# Log all matches if on session log level
				if (loglevel >= 8):
					logEntry (8, "Session match "+str(sessionentry))
				# set auth level if higher
				if (sessionentry[1] > authlevel):
					# cast this as an int - otherwise int tests fail later
					authlevel = int(sessionentry[1])
			elif (loglevel >= 10):
				logEntry (10, "Session not matched "+src+" "+str(sessionentry))
		if (loglevel >= 7):
			logEntry (7, "Highest permission current session "+str(authlevel))

		# Special case - level 10 is accept all & no log to return as OK
		if (authlevel > 9):
			sys.stdout.write("OK\n")
			sys.stdout.flush()
			continue

		# Check against rules
		# have we had an accept?
		accept = False
		# rulematch will hold the rule number that get a hit on for logging  
		rulematch = 0
		# set logentry if status is to log error
		logentry = 1
		for ruleentry in rules:
			# check rule not expired (since generated)
			if (ruleentry[4]!='*' and ruleentry[4]!= '0' and int(ruleentry[4]) < timenow):
				# may not be relevant, but we include in level 9
				if (loglevel >= 9):
					logEntry (9, "Expired rule "+str(ruleentry))
					continue
			# check if the user level matches this rule
			if (checkUserLevel(authlevel, ruleentry[3]) == False) : 
				if (loglevel >= 10) :
					logEntry (10, "User level not matched on rule "+str(ruleentry[1]))
				continue
			if (loglevel >= 10) :
				logEntry (10, "User level match on rule "+str(ruleentry[1]))
				
			# check if the destination matches
			if checkDst(dst, ruleentry[0]) :
				rulematch = ruleentry[1]
				if (loglevel >= 10) :
					logEntry (10, "Destination match on rule "+str(rulematch))
				logentry = ruleentry[5]
				# is this an accept or a deny rule
				# allow if not 0
				if (int(ruleentry[2]) != 0) :
					if (loglevel >= 10) :
						logEntry (10, "Rule "+str(rulematch)+" is allow "+str(ruleentry[2]))
					accept = True
					break
				# deny
				else :
					if (loglevel >= 10) :
						logEntry (10, "Rule "+str(rulematch)+" is deny "+str(ruleentry[2]))
					accept = False
					break
			else :
				if (loglevel >= 9):
					logEntry (9, "Rule doesn't match destination")

		if (loglevel >= 10) :
			logEntry (10, "RULES checked accept = "+str(accept))
	
			

		# if accept has been changed to True - return OK otherwise return ERR
		# if logging because it's set in rule then use loglevel 4, otherwise 5 / 6 as appropriate
		if (accept == True) :
			if (loglevel >= 4 and logentry != '0'):
				logEntry (4, "ACCEPT "+src+" -> "+dst+":"+str(dstport)+" rule:"+str(rulematch))
			elif (loglevel >= 6):
				logEntry (6, "ACCEPT "+src+" -> "+dst+":"+str(dstport)+" rule:"+str(rulematch))
			sys.stdout.write("OK\n")
		else :
			if (loglevel >= 4 and logentry != '0'):
				logEntry (4, "REJECT "+src+" -> "+dst+":"+str(dstport)+" rule:"+str(rulematch))
			elif (loglevel >= 5):
				logEntry (5, "REJECT "+src+" -> "+dst+":"+str(dstport)+" rule:"+str(rulematch))
			sys.stdout.write("ERR\n")
		sys.stdout.flush()


# Open and close the file each time so that we don't run the risk of keeping the file 
# open when another thread wants to write to it. 
def logEntry(logmessagelevel, logmessage):

	global log_file
	# Get timestamp as human readable format
	now = datetime.datetime.now()
	timestamp = now.strftime("%Y-%m-%d %H:%M:%s")
	
	# open file to apptend
	logfile = open(log_file, 'a')
	logfile.write(timestamp+" "+str(logmessagelevel)+" "+logmessage+"\n")
	logfile.close()
	return
	


def loadRules(filename):
	global loglevel
	ruleslist = list()
	
	# Read in rules file
	ruleslistfile = open(filename, 'r')
	
	# currenttime
	timenow = int(time.time());
	
	# Use linecount to track position in file - in case of error
	# read in each line
	for linecount, entry in enumerate(ruleslistfile):
		entry = entry.rstrip()
		# ignore any empty lines / comments
		if (entry and not(entry.startswith('#'))):
			thisLine = entry.split(' ')
			# check there is a valid entry (basic check of number of elements in entry)
			if (len(thisLine) < 6):
				if (loglevel >= 1):
					logEntry(1, "Invalid entry in rules file line %d \n" % (linecount))
				# Print message and abort
				#print ("Invalid entry in rules file line %d \n" % (linecount))
				# print deny
				print "ERR\n"
				sys.exit()
			# check not expired
			if (thisLine[4]!='*' and thisLine[4]!= '0' and int(thisLine[4]) < timenow):
				if (loglevel >= 9):
					logEntry (9, "Expired rule (load) "+str(entry))
					continue
				# if expired move on to next entry (ignore)
				continue
			ruleslist.append (thisLine)
	ruleslistfile.close()
	
	if (loglevel >= 2):
		logEntry(2, "loaded rules file")
	# debug level >=9 is not recommended for normal use
	if (loglevel >= 9):
		all_entries = "";
		for each_entry in ruleslist:
			all_entries += str(each_entry)+"\n"
		logEntry (9, "Rules entries:\n"+all_entries)
	return ruleslist


# returns current login level for this IP address (highest value)
def loadSessions(filename):
	global loglevel
	sessionlist = list()
	
	# Read in whitelist file
	sessionlistfile = open(filename, 'r')
	
	# currenttime
	timenow = int(time.time());
	
	# Use linecount to track position in file - in case of error
	# read in each line
	for linecount, entry in enumerate(sessionlistfile):
		entry = entry.rstrip()
		# ignore any empty lines / comments
		if (entry and not(entry.startswith('#'))):
			thisLine = entry.split(' ')
			# check there is a valid entry (basic check of number of elements in entry)
			if (len(thisLine) < 4):
				if (loglevel >=1 ):
					logEntry (1, "Invalid entry in session file line %d \n" %(linecount))
				# Print message and abort
				#print ("Invalid entry in sessions file line %d \n" % (linecount))
				print "ERR\n"
				sys.exit()
			# check not expired
			if (thisLine[3]!='*' and thisLine[3]!= '0' and int(thisLine[3]) < timenow):
				# if expired move on to next entry (ignore) - only skip here for efficiency later as we need to check this in case it changes in future anyway
				# may not be relevant, but we include in level 9 (ie higher than normal session log level)
				if (loglevel >= 9):
					logEntry (9, "Expired session (load) "+str(entry))
					continue
			sessionlist.append (thisLine)
	sessionlistfile.close()
	
	if (loglevel >= 2):
		logEntry(2, "loaded session file")
	# debug level >=9 is not recommended for normal use
	if (loglevel >= 9):
		all_entries = "";
		for each_entry in sessionlist:
			all_entries += str(each_entry)+"\n"
		logEntry (9, "Session entries:\n"+all_entries)
	return sessionlist





# function to check if a specific destination matches a particular rule
# rule should just be the domain/host part of the whitelist
def checkDst(dest, rule):
	# check for * rule (normally used to always allow for a specific source IP address or to temporarily disable
	if (rule=='*'):
		return True
	# check specific rule first - more efficient than rest
	if (dest==rule): 
		return True
	# does entry start with a . (if so then check using endswith)
	if (rule.startswith('.')) :
		if (dest.endswith(rule)):
			return True
		else :
			return False
	# least efficient - regular expression
	elif (dest.startswith('/')) :
			if re.match (rule, dest) :
				return True
			else :
				return False
	# No match
	else :
		return False



# check if our IP address matches that in the rule
# currently accept fixed IP address or regexp (add subnets in future)
def checkAddress(src, session):
	# First try a normal ip address (most likely match)
	if (src == session):
		return True
	# look for a regular expression
	elif session.startswith ('/'):
		if re.match (session, src) : 
			return True
		else : 
			return False;
	# if it's a subnet (not yet implemented)
	#elif session.find('/')
	# otherwise it's a normal IP address
	else:
		return False
		
# check to see if user level matches (supports +/- after the value)
def checkUserLevel(authlevel, ruleuser):
	# rule = * applies to all users
	if (ruleuser=='*') : return True
	# split into comma separated entries if applicable 
	listruleuser = ruleuser.split (',')
	for thisruleuser in listruleuser:
		# get int representation (with +/- removed)
		ruleuserint = int (thisruleuser.rstrip('+-'))
		# first check for exact match (don't need to know if it had +/-
		if (authlevel == ruleuserint) : return True
		# check with +
		if (ruleuser.endswith('+')) :
			if (authlevel > ruleuserint) : return True
		elif (ruleuser.endswith('-')) :
			if (authlevel < ruleuserint) : return True
	# if not matched
	return False


# - Added inline instead as more efficient than function call
## function to check if a particular rule has expired
## uses unix time stamp - or * for no expiry
#def checkExpire(expiretime):
#	if (expiretime == '*' or expiretime == '0'): return True
#	timenow = int(time.time());
#	if (int(expiretime) > timenow): return True;
#	return False


# Start
if __name__ == '__main__':
	main()

