#!/usr/bin/env python

import os, sys, glob, re, shutil, getopt, popen2, time, fnmatch
import ConfigParser, urlparse, pwd, grp, stat, syslog
import difflib, smtplib, gzip, md5, sha

VERSION = '0.5.1'

enable = ('yes', 'on', 'true', '1')
disable = ('no', 'off', 'false', '0')

excludelist = 'CVS CVS.adm RCS RCSLOG SCCS TAGS cvslog.* tags .make.state .nse_depinfo *~ #* .#* ,* _$* *$ *.old *.bak *.BAK *.orig *.rej .del-* *.a *.olb *.o *.lo *.la *.obj *.so *.exe *.Z *.elc *.ln core core.[0-9]* .svn *.rpmorig *.rpmnew *.rpmsave .DS_Store'

class Options:
	def __init__(self, args):
		self.configfile = '/etc/dconf.conf'
		self.dist = None
		self.output = None
		self.quiet = False
		self.verbose = 1

		try:
			opts, args = getopt.getopt (args, 'c:ho:qv',
				['config=', 'help', 'output=', 'quiet', 'verbose', 'version'])
		except getopt.error, exc:
			print 'dconf: %s, try dconf -h for a list of all the options' % str(exc)
			sys.exit(1)

		for opt, arg in opts:
			if opt in ['-c', '--config']:
				self.configfile = os.path.abspath(arg)
			elif opt in ['-h', '--help']:
				self.usage()
				self.help()
				sys.exit(0)
			elif opt in ('-o', '--output'):
				self.output=arg
			elif opt in ['-q', '--quiet']:
				self.quiet = True
			elif opt in ['-v', '--verbose']:
				self.verbose = self.verbose + 1
			elif opt in ['--version']:
				self.version()
				sys.exit(0)

		if self.quiet:
			self.verbose = 0

		if self.verbose >= 3:
			print 'Verbosity set to level %d' % (self.verbose - 1)
			print 'Using configfile %s' % self.configfile

	def version(self):
		print 'dconf %s' % VERSION
		print 'Written by Dag Wieers <dag@wieers.com>'
		print
		print 'platform %s/%s' % (os.name, sys.platform)
		print 'python %s' % sys.version

	def usage(self):
		print 'usage: dconf [-q] [-v] [-c config] [-o output]'

	def help(self):
		print '''Create a system's hardware and software configuration snapshot

Dconf options:
  -c, --config=file    specify alternative configfile
  -o, --output=file    write output to given file
  -q, --quiet          minimal output
  -v, --verbose        increase verbosity
  -vv, -vvv            increase verbosity more
'''

class Config:
	def __init__(self):
		self.sections = {}

		self.includefile(op.configfile)
		self.include = self.getoption('main', 'include', None)

	def includefile(self, configfile):
		self.cfg = ConfigParser.ConfigParser()

		(s,b,p,q,f,o) = urlparse.urlparse(configfile)
		if s in ('http', 'ftp', 'file'):
			configfh = urllib.urlopen(configfile)
			try:
				self.cfg.readfp(configfh)
			except ConfigParser.MissingSectionHeaderError, e:
				die(6, 'Error accessing URL: %s' % configfile)
		else:
			if os.access(configfile, os.R_OK):
				try:
					self.cfg.read(configfile)
				except:
					die(7, 'Syntax error reading file: %s' % configfile)
			else:
				die(6, 'Error accessing file: %s' % configfile)

		self.compression = self.getoption('main', 'compression', 'gzip')
		self.cron = self.getoption('main', 'cron', None)
		self.logdir = self.getoption('main', 'logdir', '/var/log/dconf')
		self.mailto = self.getoption('main', 'mailto', None)
		self.smtpserver = self.getoption('main', 'smtp-server', 'localhost')
		self.rpm = not self.getoption('main', 'rpm', 'no') in disable
		self.exclude = self.getoption('main', 'exclude', excludelist).split()

		if not op.output:
			op.output = self.getoption('main', 'output', None)

		self.quiet = not self.getoption('main', 'quiet', 'no') in disable
		if op.verbose == 1 and self.quiet:
			op.verbose = 0

		sections = self.cfg.sections()
		sections.sort()
		for section in sections:
			if section in ['main']:
				continue
			else:
				self.sections[section] = {}
				for option in self.cfg.options(section):
					if option in ('cmds', 'dirs', 'files'):
						self.sections[section][option] = self.getoption(section, option, '').split('\n')

	def getoption(self, section, option, var):
		"Get an option from a section from configfile"
		try:
			var = self.cfg.get(section, option)
#			info(3, 'Setting option %s in section [%s] to: %s' % (option, section, var))
		except ConfigParser.NoSectionError, e:
#			info(4, 'Failed to find section [%s] in %s' % (section, op.configfile))
			pass
		except ConfigParser.NoOptionError, e:
#			info(4, 'Setting option %s in section [%s] to: %s (default)' % (option, section, var))
			pass
		return var

def dzopen(filename, arg='r'):
	"Opens a file using compression based on file's extension"
	if fnmatch.fnmatch(filename, '*.gz'):
		return gzip.open(filename, arg)
	elif fnmatch.fnmatch(filename, '*.bz2'):
		return BZ2File.open(filename, arg)
	else:
		return open(filename, arg)

def md5sum(filename):
	"Return md5 from file"
	md5o = md5.new(); f = dzopen(filename); md5o.update(f.read()); f.close()
	return md5o

### FIXME: Also check timestamps/owner/perms
def md5check(h, filename):
	"Check if md5sum is the same as in rpmdb"
	if os.path.exists(filename):
		for file in h.fiFromHeader():
			if file[0] == filename:
				if md5sum(filename).hexdigest() == file[12]:
					return True
	return False

def sha1sum(filename):
	"Return sha1 from file"
	sha1o = sha.new(); f = dzopen(filename); sha1o.update(f.read()); f.close()
	return sha1o

### BOGUS function, rpmdb only has md5 ?
def sha1check(h, filename):
	"Check if sha1sum is the same as in rpmdb"
	if os.path.exists(filename):
		for file in h.fiFromHeader():
			if file[0] == filename:
				if sha1sum(filename).hexdigest() == file[12]:
					return True
	return False

def fromrpmdb(filename):
	"Check if file is inside rpmdb"
	if not ts: return None
	mi = ts.dbMatch('basenames', filename)
	for h in mi:
		return h
	else:
		info(5, 'File %s not in rpmdb, including' % filename)
		return None

def info(level, str):
	"Output info message"
	if level <= op.verbose:
		print str

def die(ret, str):
	"Print error and exit with errorcode"
	info(0, str)
	sys.exit(ret)

def cleanup():
	"Clean up logfile when interrupted."
	logfile = os.path.join(cf.logdir, 'dconf-' + hostname + '-' + timestamp + '.log' + extension)
	if os.path.isfile(logfile):
		remove(logfile)

def symlink(src, dst):
	"Create a symbolic link, force if dst exists"
	if not os.path.islink(dst) and os.path.isdir(dst):
		dst = os.path.join(dst, os.path.basename(src))
### Not using filecmp increases speed with 15%
#	if os.path.isfile(dst) and filecmp.cmp(src, dst) == 0:
	if os.path.isfile(dst):
		os.unlink(dst)
	if os.path.islink(dst):
		os.unlink(dst)
	mkdir(os.path.dirname(dst))
	if not os.path.exists(dst):
		os.symlink(src, dst)

def remove(*files):
	"Remove files or directories"
	for file in files:
		if os.path.islink(file):
			os.unlink(file)
		elif os.path.isdir(file):
			try:
				os.rmdir(file)
			except:
				os.path.walk(file, removedir, ())
		elif os.path.exists(file):
			os.unlink(file)

def removedir(void, dir, files):
	for file in files:
		remove(os.path.join(dir, file))

def mkdir(path):
	"Create a directory, and parents if needed"
	if not os.path.exists(path):
		os.makedirs(path)

def getowner(uid, gid):
	try:
		owner = pwd.getpwuid(uid)[0]
	except:
		owner = uid
	try:
		group = grp.getgrgid(gid)[0]
	except:
		group = gid
	return owner, group

def sectiontitle(section):
	info(3, 'Processing section [%s]' % section)
	stitle = '== ' + section.upper() + ' =='
	return stitle + '=' * (80 - len(stitle)) + '\n'

def which(cmd):
	"Find executables in PATH environment"
	for path in os.environ.get('PATH','$PATH').split(':'):
		if os.path.isfile(os.path.join(path, cmd)):
			info(4, 'Found command %s in path %s' % (cmd, path))
			return os.path.join(path, cmd)
	return None

def diff(fromfile, tofile):
	"Create a unified diff from 2 files"
	msg = ''
	fromfd = dzopen(fromfile)
	tofd = dzopen(tofile)
	for line in difflib.unified_diff(fromfd.readlines(), tofd.readlines(), fromfile, tofile, os.stat(fromfile).st_mtime, os.stat(tofile).st_mtime):
		msg = msg + line
	tofd.close()
	fromfd.close()
	return msg

def fnmatches(file):
	for entry in cf.exclude:
		if fnmatch.fnmatch(file, entry):
#			info(6, 'File %s matches against glob %s' % (file, entry))
			return True
	return False

def mail(subject, msg):
	info(2, 'Sending mail to: %s' % cf.mailto)
	try:
		smtp = smtplib.SMTP(cf.smtpserver)
#		server.set_debuglevel(1)
		msg = 'Subject: [dconf] %s\n\nCurrent time:\n%s\nSystem information:\n%s\nUptime:\n%s\nCurrently logged on users:\n%s\n%s\n\n%s' % (subject, os.popen('date').read(), os.popen('uname -a').read(), os.popen('uptime').read(), os.popen('who').read(), subject, msg)
		for email in cf.mailto.split():
			smtp.sendmail('dconf@%s' % os.uname()[1], email, 'To: %s\n%s' % (email, msg))
		smtp.quit()
	except:
		info(1, 'Sending mail via %s failed.' % cf.smtpserver)

def main():
	global extension, hostname, timestamp, ts

#	if cf.rpm or which('rpm'):
	try:
		import rpm
		ts = rpm.TransactionSet()
#		ts.setVSFlags(rpm.RPMVSF_NORSA | rpm.RPMVSF_NODSA)
		ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES | rpm.RPMVSF_NOHDRCHK | rpm._RPMVSF_NODIGESTS | rpm.RPMVSF_NEEDPAYLOAD)
	except:
#	else:
		info(2, 'Disabling RPM capability since the rpm-python bindings could not be loaded.')
		cf.rpm = False
		ts = None

	if cf.compression == 'gzip':
		extension = '.log.gz'
	elif cf.compression == 'bzip2':
		extension = '.log.bz2'
	else:
		extension = '.log'

	hostname = os.uname()[1].split('.')[0]
#	hostname = os.uname()[1]
#	hostname = socket.gethostbyaddr(socket.gethostname())[0]
	timestamp = time.strftime('%Y%m%d-%H%M%S', time.localtime()) 

	os.umask(077)

#	os.setenv('LC_ALL', 'C')
	os.environ['LC_ALL'] = 'C'

	syslog.openlog('dconf[%d]' % os.getpid())
	syslog.syslog('Dconf %s started.' % VERSION)

	### Add to cron
	if cf.cron and not op.output:
		if cf.cron in ('hourly', 'daily', 'weekly', 'monthly'):
			cronfile = os.path.join('/etc', 'cron.%s' % cf.cron, 'dconf')
			if os.path.isdir(os.path.dirname(cronfile)):
				if (os.path.realpath(cronfile) != which('dconf')):
					info(2, 'Adding dconf to cron (%s).' % cf.cron)
					symlink(which('dconf'), cronfile)
			else:
				info(2, 'Path %s does not exist, ignoring.' % os.path.dirname(cronfile))
		else:
			info(2, 'Option cron should be set to hourly, daily, weekly or monthly.')

		### Remove from cron
		for item in ('hourly', 'daily', 'weekly', 'monthly'):
			if item != cf.cron:
				cronfile = os.path.join('/etc', 'cron.%s' % item, 'dconf')
				if os.path.isfile(cronfile):
					info(2, 'Removing dconf from cron (%s).' % item)
					remove(cronfile)

	if op.output == '-':
		logfile = '- (stdout)'
		log = sys.stdout
		op.quiet = True
		op.verbose = 0
	elif op.output:
		logfile = op.output
   		log = dzopen(logfile, 'w')
	else:
		logfile = os.path.join(cf.logdir, 'dconf-' + hostname + '-' + timestamp + extension)
		latestlog = os.path.join(cf.logdir, 'dconf-' + hostname + '-latest' + extension)
		previouslog = os.path.join(cf.logdir, 'dconf-' + hostname + '-previous' + extension)
		oldestlog = os.path.join(cf.logdir, 'dconf-' + hostname + '-oldest' + extension)
		mkdir(cf.logdir)
		os.chmod(cf.logdir, 0700)
		log = dzopen(logfile, 'w')

	info(2, 'Building file: %s' % logfile)
	sections = cf.sections.keys()
	sections.sort()
	for section in sections:
		stitle = False

		if cf.sections[section].has_key('cmds'):
			for line in cf.sections[section]['cmds']:
				cmdline = line.split()
				if not cmdline: continue

				cmd = which(cmdline[0].strip())
				extra = ' '.join(cmdline[1:])
				if not cmd:
					info(5, 'Cmd %s not found in PATH, excluding.' % cmdline[0].strip())
					continue

				if not os.access(cmd, os.X_OK):
					info(1, 'Cmd %s cannot be executed, excluding.' % cmd)
					continue

				if not stitle:
					stitle = True
					log.write(sectiontitle(section))

				info(4, 'Processing cmd %s' % cmd)
				(mode, t, t, t, uid, gid, size, t, t, t) = os.stat(cmd)
				owner, group = getowner(uid, gid)
				title = '--[ Cmd: %s %s ]--(%04o, %s, %s, %s)--' % (cmd, extra, stat.S_IMODE(mode), owner, group, size)
				log.write(title + '-' * (80 - len(title)) + '\n')
				(o, i) = popen2.popen4('%s %s' % (cmd, extra))
				log.write(o.read() + '\n')

		if cf.sections[section].has_key('files'):
			for line in cf.sections[section]['files']:
				flist = line.split('|')
				if not flist: continue
				extra = '|'.join(flist[1:])
				if extra: extra = extra + ' '
				for file in glob.glob(flist[0].strip()):
					if not file or not os.path.isfile(file): continue

					if fnmatches(file):
						info(5, 'File %s is in the exclude list, excluding.' % file)
						continue

					(mode, t, t, t, uid, gid, size, t, t, t) = os.stat(file)
					if not size: continue

					if ts:
						h = fromrpmdb(file)
						if h and md5check(h, file):
							info(5, 'File %s has not been changed since installation, excluding.' % file)
							continue

					if not os.access(file, os.R_OK):
						info(1, 'File %s cannot be read, excluding.' % file)
						continue

					if not stitle:
						stitle = True
						log.write(sectiontitle(section))

					info(4, 'Processing file %s' % file)
					owner, group = getowner(uid, gid)
					title = '--[ File: %s %s]--(%04o, %s, %s, %s)--' % (file, extra, stat.S_IMODE(mode), owner, group, size)
					log.write(title + '-' * (80 - len(title)) + '\n')
					(o, i) = popen2.popen4('cat %s %s' % (file, extra))
					log.write(o.read() + '\n')
	log.close()

	syslog.syslog('Dconf %s ended succesfully.' % VERSION)
	syslog.closelog()

	if op.output:
		return

	if os.path.isfile(latestlog):
		if sha1sum(latestlog).digest() != sha1sum(logfile).digest():
			info(2, 'New logfile is different than last logfile, keeping.')
			symlink(os.path.basename(os.path.realpath(latestlog)), previouslog)
			symlink(os.path.basename(logfile), latestlog)
			if cf.mailto:
				mail('changes to %s' % os.uname()[1], diff(os.path.realpath(previouslog), os.path.realpath(latestlog)))
		else:
			info(2, 'New logfile is identical to last logfile, removing.')
			remove(logfile)
	else:
		info(2, 'Marking this as a first time run, symlinking.')
		symlink(os.path.basename(logfile), oldestlog)
		symlink(os.path.basename(logfile), previouslog)
		symlink(os.path.basename(logfile), latestlog)
		if cf.mailto:
			mail('initial config for %s' % os.uname()[1], dzopen(logfile, 'r').read())

### Workaround for python <= 2.2.1
try:
     True, False
except NameError:
     True = 1
     False = 0

### Main entrance
if __name__ == '__main__':
	op=Options(sys.argv[1:])
	cf=Config()
	for cfgfile in cf.include.split():
		if os.path.isfile(cfgfile):
			info(2, 'Processing configfile %s.' % cfgfile)
			cf.includefile(cfgfile)
		else:
			info(3, 'Configfile %s does not exist, ignoring.')

	try:
		main()
	except KeyboardInterrupt, e:
		cleanup()
		die(6, 'Exiting on user request')
	except:
		cleanup()
		raise

# vim:ts=4:sw=4
