''' webq.py - An service for sagator's quarantine over HTTP. (c) 2005-2010 Jan ONDREJ (SAL) 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. ''' import re, db, gettext, avlib from aglib import * from avir.basic import const from avlib import safe, trans_class, _ from BaseHTTPServer import HTTPServer from SimpleHTTPServer import SimpleHTTPRequestHandler from cgi import parse_qs from crypt import crypt import base64, binascii from cStringIO import StringIO from email import message_from_string __all__ = ['webq', 'webq_jinja'] # load neccessary libraries for chroot crypt("sagator", '$1$sagator$') class condition: def __init__(self, WA): self.WA=WA def like(self, key, value): if not value: return None if value[:1]=='!': self.WA.append(value[1:]) return key+" NOT LIKE %s" else: self.WA.append(value) return key+" LIKE %s" def date(self, key, value, dir): if not value: return None self.WA.append(value) return "%s %s %%s" % (key, dir) def eq(self, key, user): if not user: return None if user[:1]=='!': oper="!=" user=user[1:] else: oper="=" self.WA.append(user) return key+oper+'%s' def utf_header(headers, hdr, i18n): if headers[hdr]==None: return [] else: return (i18n+' '+unicode(headers[hdr], errors='replace')).split('\n') ## Genshi ########### class webq(ServiceTCPServer,HTTPServer): ''' Web service for sagator's quaratine access. This service can be used to access email collected by sagator via web interface. Requirements: python-genshi-0.4 or higher Usage: webq(host='0.0.0.0', port=8008, db, log='/var/log/sagator/webq.log', scanner, userconv) Where: host is an string, which defines IP address to bind, default: 0.0.0.0 port is an integer, which defines tcp port to listen, default: 8008 db is a database connection. For description see Databases.txt. log is defining a log file name, by default /var/log/sagator/webq.log scanner is a scanner to use for checking (only one scanner can be used here and it must be a buffer scanner!) userconv is an array, which defines regular expression and substitution strings. Usernames from login prompt are marched against this regular expression and substitued by substitution string. It is recommended to use apache mod_proxy module to redirect standard web traffic from port 80 to webq()'s 8008. For example: ProxyPass /webq http://localhost:8008 ProxyPassReverse /webq http://localhost:8008 Example: See default config file for example. New in version 0.9.0. Obsolete since 1.3.0, use webq_jinja() instead. ''' name='webq()' def __init__(self, host='0.0.0.0', port=8008, db=None, log='/var/log/sagator/webq.log', scanner=const(0), userconv=['^(.*)$','\\1']): self.BINDTO=(host, port) self.LOGFILE=log self.SCANNERS=[scanner] self.WORK_DIR=os.path.dirname(os.path.abspath(avlib.__file__)) self.WEB_ROOT=os.path.join(self.WORK_DIR, 'srv/web') self.REQUEST_HANDLER=webq_genshi_request_handler self.REQUEST_HANDLER.ENV={ '_': _, 'DB': db, 'SCANNER': scanner, 'REQUESTS': {}, 'REQ': {}, 'LANGS': ['en_US', 'sk_SK'], 'condition': condition, 'check_passwd': self.check_passwd, 'change_passwd': self.change_passwd } from genshi.template import TemplateLoader # email parser is required to run in chroot import email email.message_from_string('') # ignore output, just load module self.REQUEST_HANDLER.load_template = TemplateLoader().load self.REQUEST_HANDLER.USERCONV_REG = re.compile(userconv[0]) self.REQUEST_HANDLER.USERCONV_REPL = userconv[1] try: import cracklib except ImportError, err: try: # debian hack to load renamed cracklib import crack as cracklib except ImportError: cracklib = None debug.echo(1, "webq(): Cracklib import failed (%s), " "disabling cracklib functionality." % err) self.cracklib = cracklib if cracklib: self.cracklib_trans = trans_class('cracklib') def test_scanners(self, scanner): ServiceTCPServer.test_scanners(self, self.SCANNERS) # copy all templates import shutil try: import srv.web except ImportError: debug.echo(1, 'webq(): ERROR: webq not installed, ' 'please install sagator-webq package') raise webqsource=os.path.abspath(os.path.dirname(srv.web.__file__)) webqtarget=safe.fn(self.WEB_ROOT) if webqsource!=webqtarget: try: if not os.path.isdir(webqtarget): debug.echo(9, "Making webq chroot directory: ", webqtarget) os.makedirs(webqtarget) for f in os.listdir(webqsource): if os.path.isfile(os.path.join(webqsource, f)): debug.echo(9, "Copying webq file: ", f) shutil.copy(os.path.join(webqsource, f), os.path.join(webqtarget, f)) except OSError, e: debug.echo(9, "OSError: %s" % e) def serve_forever(self, poll_interval=0.5): if safe.fn('/usr')=='/usr': os.chdir(self.WORK_DIR) sys.path.insert(0, 'srv/web') import srv.web # reimport for chroot self.sighup(0,0) # redirect logs HTTPServer.serve_forever(self) def sighup(self,sn,stack): if debug.logfile=="-": return # reopen logs os.close(1) os.open(self.LOGFILE, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0640) os.dup2(1,2) # copy to stderr fd def check_passwd(self, DB, login, old): passwd, perms, lang, showrows = DB.query( "SELECT pass,perms,lang,showrows " "FROM webaccess WHERE email=%s", [login] )[0] if passwd!=crypt(old, passwd): return dict() return dict( REMOTE_LOGIN = login, PERMS = perms, LANG = lang or "en_US", SHOW_ROWS = showrows or 50 ) def change_passwd(self, DB, login, old, new, retyped, lang='C'): if not new: return "" if not self.check_passwd(DB, login, old): return "Wrong password!" if self.cracklib: try: e = self.cracklib.FascistCheck(new) except ValueError, e: pass if e and e!=new: self.cracklib_trans.set_lang(lang) return _('Password %s.') % self.cracklib_trans.gettext(str(e)) if new != retyped: return "New and retyped passwords does not match!" DB.execute("UPDATE webaccess SET pass=%s WHERE email=%s", [crypt(new, '$1$'+randomchars(8)), login]) return "Password changed successfully." class webq_genshi_request_handler(SimpleHTTPRequestHandler): def do_POST(self): return self.send_head() def send_head(self): path = self.path i = path.rfind('?') if i>=0: path, query = path[:i], path[i+1:] else: query = '' if not path.strip('/'): path = '/index.html' scriptfile = self.translate_path("srv/web/"+path) if not os.path.exists(scriptfile): self.send_error(404, "No such template (%r)" % scriptfile) return if not os.path.isfile(scriptfile): self.send_error(403, "Template is not a plain file (%r)" % path) return ns=self.ENV.copy() if query: ns['QUERY_STRING'] = query ns['REQUESTS'].update(parse_qs(query, True)) length = int(self.headers.getheader('content-length') or '0') if length > 0: data=self.rfile.read(length) ns['REQUESTS'].update(parse_qs(data, True)) for key,value in ns['REQUESTS'].items(): ns['REQ'][key]=value[0].decode('UTF-8') ns['CONTENT_LENGTH'] = length ns['REMOTE_ADDR'] = self.client_address[0] ns['REMOTE_USER'] = '' ns['REMOTE_LOGIN'] = '' ns['PERMS'] = '' ns['LANG'] = 'C' ns.update(self.check_auth()) ns['_'].set_lang(ns['LANG']) if not ns.get('REMOTE_USER'): self.send_response(401, "Authorization Required") self.send_header("WWW-Authenticate", 'Basic realm="Restricted access"') self.end_headers() self.wfile.write("Authorization Required") return try: template = self.load_template(scriptfile) s = template.generate(**ns).render('xhtml') self.send_response(200, "Script output follows") self.send_header("Content-type", "text/html; charset=UTF-8") self.send_header("Content-Length", str(len(s))) self.end_headers() self.wfile.write(s) except Exception, e: import traceback s=traceback.format_exc() self.send_response(500, "Internal server error!") self.send_header("Content-type", "text/plain; charset=UTF-8") self.send_header("Content-Length", str(len(s))) self.end_headers() self.wfile.write(s) def check_auth(self): ''' Check authorization request. Return value is an empty dictionary, if authorization fails or an namespace update on success. ''' env={} authorization = self.headers.getheader("authorization") if authorization: authorization = authorization.split() if len(authorization) == 2: env['AUTH_TYPE'] = authorization[0] if authorization[0].lower() == "basic": try: authorization = base64.decodestring(authorization[1]) except binascii.Error: pass else: authorization = authorization.split(':') if len(authorization) == 2: env.update(self.check_pass(authorization)) return env def check_pass(self, auth): ''' Check user password. Parameter auth is an array of: ['login','plain text password'] Return value is an empty dictionary, if authorization fails, or an namespace update on success. ''' try: passwd,perms,lang,showrows = self.ENV['DB'].query( "SELECT pass,perms,lang,showrows FROM webaccess WHERE email=%s", auth[:1] )[0] except IndexError: return {} # no record found for this login cryptpass = crypt(auth[1], passwd) if passwd!=cryptpass: return {} return { 'REMOTE_USER': self.USERCONV_REG.sub(self.USERCONV_REPL, auth[0]), 'REMOTE_LOGIN': auth[0], 'PERMS': perms, 'LANG': lang or "en_US", 'SHOW_ROWS': showrows or 50 } ## Jinja2 ########### class namespace(dict): def __init__(self, **kw): from etc import SMTP_SERVER self.smtp_server = SMTP_SERVER dict.__init__(self, **kw) self['int'] = int self['unicode'] = unicode def reinit(self): self['get_index'] = self.get_index self['action_view'] = self.action_view self['action_recheck'] = self.action_recheck self['action_deliver'] = self.action_deliver self['set_rows_and_lang'] = self.set_rows_and_lang self['set_password'] = self.set_password self['check_permission'] = self.check_permission def __getattr__(self, name): return self.get(name) def get_index(self): try: QS = "SELECT qname,datetime,virname,level,sender,size,status,recipient"\ " FROM log" QA = [] # conditions WS,WA = [],[] cond = condition(WA) WS.append(cond.like('virname', self.REQ.get('virname'))) WS.append(cond.like('sender', self.REQ.get('sender'))) WS.append(cond.like('status', self.REQ.get('status', '!SENT'))) WS.append(cond.date('begin', self.REQ.get('datetime'), '>=')) WS.append(cond.date('end', self.REQ.get('datetime'), '<=')) if 'A' in self.PERMS: WS.append(cond.like('recipient', self.REQ.get('recipient'))) else: WS.append(cond.eq('recipient', self.REMOTE_USER)) WS=[x for x in WS if x] if WS: QS += " WHERE "+" AND ".join(WS) QA = WA QS += " ORDER BY datetime DESC LIMIT %s OFFSET %s" QA.append(self.SHOW_ROWS) QA.append(int(self.REQ.get('offset', 0))) data = self.DB.query(QS,QA) return dict(rows=self.DB.rowcount, data=data, error='') except Exception, e: return dict(rows=0, data=[], error=r) def set_rows_and_lang(self, rows, lng): if (rows!=self.SHOW_ROWS) or (lng!=self.LANG): # store rows self.DB.execute("UPDATE webaccess SET showrows=%s, lang=%s" "WHERE email=%s", [rows, lng, self.REMOTE_LOGIN]) return rows, lng return self.SHOW_ROWS, self.LANG def set_password(self, REMOTE_LOGIN, REQ): try: return self.change_passwd( self.DB, REMOTE_LOGIN, REQ.get('old'), REQ.get('new'), REQ.get('retyped'), self.LANG ) except Exception, e: return e def check_permission(self, qn): error = "" try: qname = safe.fn(qn) if 'A' in self.PERMS: self.DB.query("SELECT qname FROM log WHERE qname=%s", [qn]) else: self.DB.query( "SELECT qname FROM log WHERE qname=%s AND recipient=%s", [qn, self.REMOTE_USER] ) if self.DB.rowcount==0: error = "%s: Permission denied or no quarantined file found!" \ % qname if not os.path.isfile(qname): error = "File %s does not exist!" % qname except Exception, e: error = "Internal error occurred [%s]!" % e return dict( error=error, qname=qname ) def action_view(self, qname): try: email = qfile() email.parse(open(qname, 'rb')) # parse headers headers = message_from_string(email.message()) show_headers = \ utf_header(headers, 'From', _('From:')) + \ utf_header(headers, 'To', _('To:')) + \ utf_header(headers, 'Date', _('Date:')) + \ utf_header(headers, 'Subject', _('Subject:')) for key in headers.keys(): if key[:7]=='X-Spam-' or key[:10]=='X-Sagator-': show_headers.extend(utf_header(headers, key, key+':')) return dict( show_headers=show_headers, email=email ) except IOError, error: pass except Exception, e: print "action.html:", e return dict(show_headers='', email=None) def action_recheck(self, qname): try: email = qfile() email.parse(open(qname, 'rb')) level, virname, status = self['SCANNER'].scanbuffer(email.message()) self['SCANNER'].destroy() if not virname: return dict( level=level, virname=_("CLEAN"), div_class="clean", error='' ) else: return dict( level=level, virname=virname, div_class="infected", error='' ) except Exception, error: return dict( level=0, virname='', div_class="clean", error=error ) def action_deliver(self, qname): try: return dict( status=send_qfile(qname, self.smtp_server), error='' ) except Exception, error: return dict(status='', error=error) class webq_jinja_request_handler(SimpleHTTPRequestHandler): def do_POST(self): return self.send_head() def send_head(self): path = self.path i = path.rfind('?') if i>=0: path, query = path[:i], path[i+1:] else: query = '' if not path.strip('/'): path = '/index.html' scriptfile = self.translate_path("srv/templates/"+path) if not os.path.exists(scriptfile): self.send_error(404, "No such template (%r)" % scriptfile) return if not os.path.isfile(scriptfile): self.send_error(403, "Template is not a plain file (%r)" % path) return ns = namespace() # copy defaults ns.update(self.ENV) if query: ns['QUERY_STRING'] = query ns['REQUESTS'].update(parse_qs(query, True)) length = int(self.headers.getheader('content-length') or '0') if length > 0: data=self.rfile.read(length) ns['REQUESTS'].update(parse_qs(data, True)) for key,value in ns['REQUESTS'].items(): ns['REQ'][key]=value[0].decode('UTF-8') ns['CONTENT_LENGTH'] = length ns['REMOTE_ADDR'] = self.client_address[0] ns.update(self.check_auth()) ns['_'].set_lang(ns['LANG']) # other ns.reinit() show_rows = int(ns.get('SHOW_ROWS', 0)) ns['prev_page'] = max(0, int(ns['REQ'].get('offset', 0)) - show_rows) ns['next_page'] = int(ns['REQ'].get('offset', 0)) + show_rows ns['query'] = '&'.join([ "%s=%s" % (x,y) for x,y in ns['REQ'].items() if x!="offset" ]) if not ns.get('REMOTE_USER'): self.send_response(401, "Authorization Required") self.send_header("WWW-Authenticate", 'Basic realm="Restricted access"') self.end_headers() self.wfile.write("Authorization Required") return try: template = self.load_template(scriptfile.split('/')[-1]) s = template.render(**ns) self.send_response(200, "Script output follows") self.send_header("Content-type", "text/html; charset=UTF-8") except Exception, e: import traceback s = traceback.format_exc() self.send_response(500, "Internal server error!") self.send_header("Content-type", "text/plain; charset=UTF-8") s = s.encode('UTF-8') self.send_header("Content-Length", str(len(s))) self.end_headers() self.wfile.write(s) def check_auth(self): ''' Check authorization request. Return value is an empty dictionary, if authorization fails or an namespace update on success. ''' env={} authorization = self.headers.getheader("authorization") if authorization: authorization = authorization.split() if len(authorization) == 2: env['AUTH_TYPE'] = authorization[0] if authorization[0].lower() == "basic": try: authorization = base64.decodestring(authorization[1]) except binascii.Error: pass else: authorization = authorization.split(':') if len(authorization) == 2: env.update(self.check_pass(authorization)) return env def check_pass(self, auth): ''' Check user password. Parameter auth is an array of: ['login','plain text password'] Return value is an empty dictionary, if authorization fails, or an namespace update on success. ''' try: passwd,perms,lang,showrows = self.ENV['DB'].query( "SELECT pass,perms,lang,showrows FROM webaccess WHERE email=%s", auth[:1] )[0] except IndexError: return {} # no record found for this login cryptpass = crypt(auth[1], passwd) if passwd!=cryptpass: return {} return { 'REMOTE_USER': self.USERCONV_REG.sub(self.USERCONV_REPL, auth[0]), 'REMOTE_LOGIN': auth[0], 'PERMS': perms, 'LANG': lang or "en_US", 'SHOW_ROWS': showrows or 50 } class webq_jinja(ServiceTCPServer, HTTPServer): ''' Web service for sagator's quaratine access. This service can be used to access email collected by sagator via web interface. Requirements: python-jinja2 or python-jinja Usage: webq_jinja(host='0.0.0.0', port=8008, db, log='/var/log/sagator/webq.log', scanner, userconv) Where: host is an string, which defines IP address to bind, default: 0.0.0.0 port is an integer, which defines tcp port to listen, default: 8008 db is a database connection. For description see Databases.txt. log is defining a log file name, by default /var/log/sagator/webq.log scanner is a scanner to use for checking (only one scanner can be used here and it must be a buffer scanner!) userconv is an array, which defines regular expression and substitution strings. Usernames from login prompt are marched against this regular expression and substitued by substitution string. request_handler is an SimpleHTTPRequestHandler class. By default webq_jinja_request_handler is used. Use this class as parent if you need to override some functions. This parameter was introduced in sagator 1.3. It is recommended to use apache mod_proxy module to redirect standard web traffic from port 80 to webq()'s 8008. For example: ProxyPass /webq http://localhost:8008 ProxyPassReverse /webq http://localhost:8008 Example: See default config file for example. New in version 1.3.0. ''' name='webq_jinja()' def __init__(self, host='0.0.0.0', port=8008, db=None, log='/var/log/sagator/webq.log', scanner=const(0), userconv=['^(.*)$','\\1'], request_handler=webq_jinja_request_handler): self.BINDTO = (host, port) self.LOGFILE = log self.SCANNERS = [scanner] self.WORK_DIR = os.path.dirname(os.path.abspath(avlib.__file__)) self.WEB_ROOT = os.path.join(self.WORK_DIR, 'srv/templates') self.REQUEST_HANDLER = request_handler self.REQUEST_HANDLER.ENV = namespace( _ = _, DB = db, SCANNER = scanner, REQUESTS = {}, REQ = {}, LANGS = ['en_US', 'sk_SK'], LANG = 'C', REMOTE_ADDR = '', REMOTE_USER = '', REMOTE_LOGIN = '', PERMS = '', condition = condition, check_passwd = self.check_passwd, change_passwd = self.change_passwd ) TemplateLoader = self.get_template_loader() self.REQUEST_HANDLER.load_template = TemplateLoader.get_template self.REQUEST_HANDLER.USERCONV_REG = re.compile(userconv[0]) self.REQUEST_HANDLER.USERCONV_REPL = userconv[1] try: import cracklib except ImportError, err: try: # debian hack to load renamed cracklib import crack as cracklib except ImportError: cracklib = None debug.echo(1, "webq(): Cracklib import failed (%s), " "disabling cracklib functionality." % err) self.cracklib = cracklib if cracklib: self.cracklib_trans = trans_class('cracklib') def get_template_loader(self): try: from jinja2 import Environment, PackageLoader except ImportError: from jinja import Environment, PackageLoader return Environment(loader=PackageLoader('srv', 'templates')) def test_scanners(self, scanner): ServiceTCPServer.test_scanners(self, self.SCANNERS) # copy all templates import shutil try: import srv.templates except ImportError: debug.echo(1, 'webq(): ERROR: webq not installed, ' 'please install sagator-webq package') raise webqsource = os.path.abspath(os.path.dirname(srv.templates.__file__)) webqtarget = safe.fn(self.WEB_ROOT) if webqsource!=webqtarget: try: if not os.path.isdir(webqtarget): debug.echo(9, "Making webq chroot directory: ", webqtarget) os.makedirs(webqtarget) for f in os.listdir(webqsource): if os.path.isfile(os.path.join(webqsource, f)): debug.echo(9, "Copying webq file: ", f) shutil.copy(os.path.join(webqsource, f), os.path.join(webqtarget, f)) except OSError, e: debug.echo(9, "OSError: %s" % e) def serve_forever(self, poll_interval=0.5): if safe.fn('/usr')=='/usr': os.chdir(self.WORK_DIR) sys.path.insert(0, 'srv/templates') import srv.templates # reimport for chroot self.sighup(0,0) # redirect logs HTTPServer.serve_forever(self) def sighup(self,sn,stack): if debug.logfile=="-": return # reopen logs os.close(1) os.open(self.LOGFILE, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0640) os.dup2(1,2) # copy to stderr fd def check_passwd(self, DB, login, old): ''' Check, if new and old passwords are same. ''' passwd, perms, lang, showrows = DB.query( "SELECT pass,perms,lang,showrows " "FROM webaccess WHERE email=%s", [login] )[0] if passwd!=crypt(old, passwd): return dict() return dict( REMOTE_LOGIN = login, PERMS = perms, LANG = lang or "en_US", SHOW_ROWS = showrows or 50 ) def change_passwd(self, DB, login, old, new, retyped, lang='C'): if not new: return "" if not self.check_passwd(DB, login, old): return "Wrong password!" if self.cracklib: try: e = self.cracklib.FascistCheck(new) except ValueError, e: pass if e and e!=new: self.cracklib_trans.set_lang(lang) return _('Password %s.') % self.cracklib_trans.gettext(str(e)) if new != retyped: return "New and retyped passwords does not match!" DB.execute("UPDATE webaccess SET pass=%s WHERE email=%s", [crypt(new, '$1$'+randomchars(8)), login]) return "Password changed successfully."