[bridgedb/master] Split servers into separate files

commit 120c8e6d4b9d714e79722d83c19e110120977e79 Author: aagbsn <aagbsn@extc.org> Date: Mon Mar 18 19:55:11 2013 +0000 Split servers into separate files --- lib/bridgedb/EmailServer.py | 478 +++++++++++++++++++++++++ lib/bridgedb/HTTPServer.py | 351 +++++++++++++++++++ lib/bridgedb/Server.py | 809 ------------------------------------------- 3 files changed, 829 insertions(+), 809 deletions(-) diff --git a/lib/bridgedb/EmailServer.py b/lib/bridgedb/EmailServer.py new file mode 100644 index 0000000..8b913a1 --- /dev/null +++ b/lib/bridgedb/EmailServer.py @@ -0,0 +1,478 @@ +# BridgeDB by Nick Mathewson. +# Copyright (c) 2007-2013, The Tor Project, Inc. +# See LICENSE for licensing information + +""" +This module implements the email interface to the bridge database. +""" + +from StringIO import StringIO + +import gettext +import gpgme +import logging +import re +import rfc822 +import time + +from ipaddr import IPv4Address, IPv6Address + +from twisted.internet import reactor +from twisted.internet.defer import Deferred +from twisted.internet.task import LoopingCall +import twisted.mail.smtp + +from zope.interface import implements + +import bridgedb.Dist +from bridgedb.Dist import BadEmail, TooSoonEmail, IgnoreEmail +from bridgedb.Filters import filterBridgesByIP6, filterBridgesByIP4 +from bridgedb.Filters import filterBridgesByTransport +from bridgedb.Filters import filterBridgesByNotBlockedIn + +import bridgedb.I18n as I18n + +class MailFile: + """A file-like object used to hand rfc822.Message a list of lines + as though it were reading them from a file.""" + def __init__(self, lines): + self.lines = lines + self.idx = 0 + def readline(self): + try : + line = self.lines[self.idx] + self.idx += 1 + return line + except IndexError: + return "" + +def getBridgeDBEmailAddrFromList(ctx, address_list): + """Loop through a list of (full name, email address) pairs and look up our + mail address. If our address isn't found (which can't happen), return + the default ctx from address so we can keep on working. + """ + email = ctx.fromAddr + for _, address in address_list: + # Strip the @torproject.org part from the address + idx = address.find('@') + if idx != -1: + username = address[:idx] + # See if the user looks familiar. We do a 'find' instead + # of compare because we might have a '+' address here + if username.find(ctx.username) != -1: + email = address + return email + +def getMailResponse(lines, ctx): + """Given a list of lines from an incoming email message, and a + MailContext object, parse the email and decide what to do in response. + If we want to answer, return a 2-tuple containing the address that + will receive the response, and a readable filelike object containing + the response. Return None,None if we shouldn't answer. + """ + # Extract data from the headers. + msg = rfc822.Message(MailFile(lines)) + subject = msg.getheader("Subject", None) + if not subject: subject = "[no subject]" + clientFromAddr = msg.getaddr("From") + clientSenderAddr = msg.getaddr("Sender") + # RFC822 requires at least one 'To' address + clientToList = msg.getaddrlist("To") + clientToaddr = getBridgeDBEmailAddrFromList(ctx, clientToList) + msgID = msg.getheader("Message-ID", None) + if clientSenderAddr and clientSenderAddr[1]: + clientAddr = clientSenderAddr[1] + elif clientFromAddr and clientFromAddr[1]: + clientAddr = clientFromAddr[1] + else: + logging.info("No From or Sender header on incoming mail.") + return None,None + + # Look up the locale part in the 'To:' address, if there is one and get + # the appropriate Translation object + lang = getLocaleFromPlusAddr(clientToaddr) + t = I18n.getLang(lang) + + try: + _, addrdomain = bridgedb.Dist.extractAddrSpec(clientAddr.lower()) + except BadEmail: + logging.info("Ignoring bad address on incoming email.") + return None,None + if not addrdomain: + logging.info("Couldn't parse domain from %r", clientAddr) + if addrdomain and ctx.cfg.EMAIL_DOMAIN_MAP: + addrdomain = ctx.cfg.EMAIL_DOMAIN_MAP.get(addrdomain, addrdomain) + if addrdomain not in ctx.cfg.EMAIL_DOMAINS: + logging.info("Unrecognized email domain %r", addrdomain) + return None,None + rules = ctx.cfg.EMAIL_DOMAIN_RULES.get(addrdomain, []) + if 'dkim' in rules: + # getheader() returns the last of a given kind of header; we want + # to get the first, so we use getheaders() instead. + dkimHeaders = msg.getheaders("X-DKIM-Authentication-Results") + dkimHeader = "<no header>" + if dkimHeaders: dkimHeader = dkimHeaders[0] + if not dkimHeader.startswith("pass"): + logging.info("Got a bad dkim header (%r) on an incoming mail; " + "rejecting it.", dkimHeader) + return None, None + + # Was the magic string included + #for ln in lines: + # if ln.strip().lower() in ("get bridges", "subject: get bridges"): + # break + #else: + # logging.info("Got a mail from %r with no bridge request; dropping", + # clientAddr) + # return None,None + + # Figure out which bridges to send + unblocked = transport = ipv6 = skippedheaders = False + bridgeFilterRules = [] + addressClass = None + for ln in lines: + # ignore all lines before the subject header + if "subject" in ln.strip().lower(): + skippedheaders = True + if not skippedheaders: continue + + if "ipv6" in ln.strip().lower(): + ipv6 = True + if "transport" in ln.strip().lower(): + try: + transport = re.search("transport ([_a-zA-Z][_a-zA-Z0-9]*)", + ln).group(1).strip() + except (TypeError, AttributeError): + transport = None + logging.debug("Got request for transport: %s" % transport) + if "unblocked" in ln.strip().lower(): + try: + unblocked = re.search("unblocked ([a-zA-Z]{2,4})", + ln).group(1).strip() + except (TypeError, AttributeError): + transport = None + + if ipv6: + bridgeFilterRules.append(filterBridgesByIP6) + addressClass = IPv6Address + else: + bridgeFilterRules.append(filterBridgesByIP4) + addressClass = IPv4Address + + if transport: + bridgeFilterRules = [filterBridgesByTransport(transport, addressClass)] + + if unblocked: + rules.append(filterBridgesByNotBlockedIn(unblocked, + addressClass, transport)) + + try: + interval = ctx.schedule.getInterval(time.time()) + bridges = ctx.distributor.getBridgesForEmail(clientAddr, + interval, ctx.N, + countryCode=None, + bridgeFilterRules=bridgeFilterRules) + + # Handle rate limited email + except TooSoonEmail, e: + logging.info("Got a mail too frequently; warning %r: %s.", + clientAddr, e) + + # Compose a warning email + # MAX_EMAIL_RATE is in seconds, convert to hours + body = buildSpamWarningTemplate(t) % (bridgedb.Dist.MAX_EMAIL_RATE / 3600) + return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID, + gpgContext=ctx.gpgContext) + + except IgnoreEmail, e: + logging.info("Got a mail too frequently; ignoring %r: %s.", + clientAddr, e) + return None, None + + except BadEmail, e: + logging.info("Got a mail from a bad email address %r: %s.", + clientAddr, e) + return None, None + + if bridges: + with_fp = ctx.cfg.EMAIL_INCLUDE_FINGERPRINTS + answer = "".join(" %s\n" %b.getConfigLine( + includeFingerprint=with_fp, + addressClass=addressClass, + transport=transport, + request=clientAddr + ) for b in bridges) + else: + answer = "(no bridges currently available)" + + body = buildMessageTemplate(t) % answer + # Generate the message. + return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID, + gpgContext=ctx.gpgContext) + + +def buildMessageTemplate(t): + msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[0]) + "\n\n" \ + + "%s\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[2]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[3]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[17])+ "\n\n" + # list supported commands, e.g. ipv6, transport + msg_template = msg_template \ + + " " + t.gettext(I18n.BRIDGEDB_TEXT[18])+ "\n" \ + + " " + t.gettext(I18n.BRIDGEDB_TEXT[19])+ "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[6]) + "\n\n" + return msg_template + +def buildSpamWarningTemplate(t): + msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[10]) + "\n\n" \ + + "%s " \ + + t.gettext(I18n.BRIDGEDB_TEXT[11]) + "\n\n" \ + + t.gettext(I18n.BRIDGEDB_TEXT[12]) + "\n\n" + return msg_template + +def replyToMail(lines, ctx): + """Given a list of lines from an incoming email message, and a + MailContext object, possibly send a reply. + """ + logging.info("Got a completed email; deciding whether to reply.") + sendToUser, response = getMailResponse(lines, ctx) + if response is None: + logging.debug("getMailResponse said not to reply, so I won't.") + return + response.seek(0) + d = Deferred() + factory = twisted.mail.smtp.SMTPSenderFactory( + ctx.smtpFromAddr, + sendToUser, + response, + d) + reactor.connectTCP(ctx.smtpServer, ctx.smtpPort, factory) + logging.info("Sending reply to %r", sendToUser) + return d + +def getLocaleFromPlusAddr(address): + """See whether the user sent his email to a 'plus' address, for + instance to bridgedb+fa@tpo. Plus addresses are the current + mechanism to set the reply language + """ + replyLocale = "en" + r = '.*(<)?(\w+\+(\w+)@\w+(?:\.\w+)+)(?(1)>)' + match = re.match(r, address) + if match: + replyLocale = match.group(3) + + return replyLocale + +def getLocaleFromRequest(request): + # See if we did get a request for a certain locale, otherwise fall back + # to 'en': + # Try evaluating the path /foo first, then check if we got a ?lang=foo + default_lang = lang = "en" + if len(request.path) > 1: + lang = request.path[1:] + if lang == default_lang: + lang = request.args.get("lang", [default_lang]) + lang = lang[0] + return I18n.getLang(lang) + +class MailContext: + """Helper object that holds information used by email subsystem.""" + def __init__(self, cfg, dist, sched): + # Reject any RCPT TO lines that aren't to this user. + self.username = (cfg.EMAIL_USERNAME or + "bridges") + # Reject any mail longer than this. + self.maximumSize = 32*1024 + # Use this server for outgoing mail. + self.smtpServer = (cfg.EMAIL_SMTP_HOST or "127.0.0.1") + self.smtpPort = (cfg.EMAIL_SMTP_PORT or 25) + # Use this address in the MAIL FROM line for outgoing mail. + self.smtpFromAddr = (cfg.EMAIL_SMTP_FROM_ADDR or + "bridges@torproject.org") + # Use this address in the "From:" header for outgoing mail. + self.fromAddr = (cfg.EMAIL_FROM_ADDR or + "bridges@torproject.org") + # An EmailBasedDistributor object + self.distributor = dist + # An IntervalSchedule object + self.schedule = sched + # The number of bridges to send for each email. + self.N = cfg.EMAIL_N_BRIDGES_PER_ANSWER + + # Initialize a gpg context or set to None for backward compatibliity. + self.gpgContext = getGPGContext(cfg) + + self.cfg = cfg + +class MailMessage: + """Plugs into the Twisted Mail and receives an incoming message. + Once the message is in, we reply or we don't. """ + implements(twisted.mail.smtp.IMessage) + + def __init__(self, ctx): + """Create a new MailMessage from a MailContext.""" + self.ctx = ctx + self.lines = [] + self.nBytes = 0 + self.ignoring = False + + def lineReceived(self, line): + """Called when we get another line of an incoming message.""" + self.nBytes += len(line) + logging.debug("> %s", line.rstrip("\r\n")) + if self.nBytes > self.ctx.maximumSize: + self.ignoring = True + else: + self.lines.append(line) + + def eomReceived(self): + """Called when we receive the end of a message.""" + if not self.ignoring: + replyToMail(self.lines, self.ctx) + return twisted.internet.defer.succeed(None) + + def connectionLost(self): + """Called if we die partway through reading a message.""" + pass + +class MailDelivery: + """Plugs into Twisted Mail and handles SMTP commands.""" + implements(twisted.mail.smtp.IMessageDelivery) + def setBridgeDBContext(self, ctx): + self.ctx = ctx + def receivedHeader(self, helo, origin, recipients): + #XXXX what is this for? what should it be? + return "Received: BridgeDB" + def validateFrom(self, helo, origin): + return origin + def validateTo(self, user): + """If the local user that was addressed isn't our configured local + user or doesn't contain a '+' with a prefix matching the local + configured user: Yell + """ + u = user.dest.local + # Hasplus? If yes, strip '+foo' + idx = u.find('+') + if idx != -1: + u = u[:idx] + if u != self.ctx.username: + raise twisted.mail.smtp.SMTPBadRcpt(user) + return lambda: MailMessage(self.ctx) + +class MailFactory(twisted.mail.smtp.SMTPFactory): + """Plugs into Twisted Mail; creates a new MailDelivery whenever we get + a connection on the SMTP port.""" + def __init__(self, *a, **kw): + twisted.mail.smtp.SMTPFactory.__init__(self, *a, **kw) + self.delivery = MailDelivery() + + def setBridgeDBContext(self, ctx): + self.ctx = ctx + self.delivery.setBridgeDBContext(ctx) + + def buildProtocol(self, addr): + p = twisted.mail.smtp.SMTPFactory.buildProtocol(self, addr) + p.delivery = self.delivery + return p + +def addSMTPServer(cfg, dist, sched): + """Set up a smtp server. + cfg -- a configuration object from Main. We use these options: + EMAIL_BIND_IP + EMAIL_PORT + EMAIL_N_BRIDGES_PER_ANSWER + EMAIL_DOMAIN_RULES + dist -- an EmailBasedDistributor object. + sched -- an IntervalSchedule object. + """ + ctx = MailContext(cfg, dist, sched) + factory = MailFactory() + factory.setBridgeDBContext(ctx) + ip = cfg.EMAIL_BIND_IP or "" + reactor.listenTCP(cfg.EMAIL_PORT, factory, interface=ip) + # Set up a LoopingCall to run every 30 minutes and forget old email times. + lc = LoopingCall(dist.cleanDatabase) + lc.start(1800, now=False) + return factory + +def composeEmail(fromAddr, clientAddr, subject, body, msgID=False, + gpgContext=None): + + f = StringIO() + w = MimeWriter.MimeWriter(f) + w.addheader("From", fromAddr) + w.addheader("To", clientAddr) + w.addheader("Message-ID", twisted.mail.smtp.messageid()) + if not subject.startswith("Re:"): subject = "Re: %s"%subject + w.addheader("Subject", subject) + if msgID: + w.addheader("In-Reply-To", msgID) + w.addheader("Date", twisted.mail.smtp.rfc822date()) + mailbody = w.startbody("text/plain") + + # gpg-clearsign messages + if gpgContext: + signature = StringIO() + plaintext = StringIO(body) + sigs = gpgContext.sign(plaintext, signature, gpgme.SIG_MODE_CLEAR) + if (len(sigs) != 1): + logging.warn('Failed to sign message!') + signature.seek(0) + [mailbody.write(l) for l in signature] + else: + mailbody.write(body) + + f.seek(0) + logging.debug("Email body:\n%s" % f.read()) + f.seek(0) + return clientAddr, f + +def getGPGContext(cfg): + """ Returns a gpgme Context() with the signers initialized by the keyfile + specified by the option EMAIL_GPG_SIGNING_KEY in bridgedb.conf, or None + if the option was not enabled or unable to initialize. + + The key should not be protected by a passphrase. + """ + try: + # must have enabled signing and specified a key file + if not cfg.EMAIL_GPG_SIGNING_ENABLED or not cfg.EMAIL_GPG_SIGNING_KEY: + return None + except AttributeError: + return None + + try: + # import the key + keyfile = open(cfg.EMAIL_GPG_SIGNING_KEY) + logging.debug("Opened GPG Keyfile %s" % cfg.EMAIL_GPG_SIGNING_KEY) + ctx = gpgme.Context() + result = ctx.import_(keyfile) + + assert len(result.imports) == 1 + fingerprint = result.imports[0][0] + keyfile.close() + logging.debug("GPG Key with fingerprint %s imported" % fingerprint) + + ctx.armor = True + ctx.signers = [ctx.get_key(fingerprint)] + assert len(ctx.signers) == 1 + + # make sure we can sign + message = StringIO('Test') + signature = StringIO() + new_sigs = ctx.sign(message, signature, gpgme.SIG_MODE_CLEAR) + assert len(new_sigs) == 1 + + # return the ctx + return ctx + + except IOError, e: + # exit noisily if keyfile not found + exit(e) + except AssertionError: + # exit noisily if key does not pass tests + exit('Invalid GPG Signing Key') diff --git a/lib/bridgedb/HTTPServer.py b/lib/bridgedb/HTTPServer.py new file mode 100644 index 0000000..df5a031 --- /dev/null +++ b/lib/bridgedb/HTTPServer.py @@ -0,0 +1,351 @@ +# BridgeDB by Nick Mathewson. +# Copyright (c) 2007-2013, The Tor Project, Inc. +# See LICENSE for licensing information + +""" +This module implements the web (http, https) interfaces to the bridge database. +""" + +import base64 +import gettext +import logging +import re +import textwrap +import time + +from twisted.internet import reactor +import twisted.web.resource +import twisted.web.server + +import bridgedb.Dist +import bridgedb.I18n as I18n + +from recaptcha.client import captcha +from bridgedb.Raptcha import Raptcha +from bridgedb.Filters import filterBridgesByIP6, filterBridgesByIP4 +from bridgedb.Filters import filterBridgesByTransport +from bridgedb.Filters import filterBridgesByNotBlockedIn +from ipaddr import IPv4Address, IPv6Address +from random import randint + +try: + import GeoIP + # GeoIP data object: choose database here + # This is the same geoip implementation that pytorctl uses + geoip = GeoIP.new(GeoIP.GEOIP_STANDARD) + logging.info("GeoIP database loaded") +except: + geoip = None + logging.warn("GeoIP database not found") + +class WebResource(twisted.web.resource.Resource): + """This resource is used by Twisted Web to give a web page with some + bridges in response to a request.""" + isLeaf = True + + def __init__(self, distributor, schedule, N=1, useForwardedHeader=False, + includeFingerprints=True, + useRecaptcha=False,recaptchaPrivKey='', recaptchaPubKey='', + domains=None): + """Create a new WebResource. + distributor -- an IPBasedDistributor object + schedule -- an IntervalSchedule object + N -- the number of bridges to hand out per query. + """ + gettext.install("bridgedb", unicode=True) + twisted.web.resource.Resource.__init__(self) + self.distributor = distributor + self.schedule = schedule + self.nBridgesToGive = N + self.useForwardedHeader = useForwardedHeader + self.includeFingerprints = includeFingerprints + + # do not use mutable types as __init__ defaults! + if not domains: domains = [] + self.domains = domains + + # recaptcha options + self.useRecaptcha = useRecaptcha + self.recaptchaPrivKey = recaptchaPrivKey + self.recaptchaPubKey = recaptchaPubKey + + def render_GET(self, request): + if self.useRecaptcha: + # get a captcha + c = Raptcha(self.recaptchaPubKey, self.recaptchaPrivKey) + c.get() + + # TODO: this does not work for versions of IE < 8.0 + imgstr = 'data:image/jpeg;base64,%s' % base64.b64encode(c.image) + HTML_CAPTCHA_TEMPLATE = self.buildHTMLMessageTemplateWithCaptcha( + getLocaleFromRequest(request), c.challenge, imgstr) + return HTML_CAPTCHA_TEMPLATE + else: + return self.getBridgeRequestAnswer(request) + + + def render_POST(self, request): + + # check captcha if recaptcha support is enabled + if self.useRecaptcha: + try: + challenge = request.args['recaptcha_challenge_field'][0] + response = request.args['recaptcha_response_field'][0] + + except: + return self.render_GET(request) + + # generate a random IP for the captcha submission + remote_ip = '%d.%d.%d.%d' % (randint(1,255),randint(1,255), + randint(1,255),randint(1,255)) + + recaptcha_response = captcha.submit(challenge, response, + self.recaptchaPrivKey, remote_ip) + if recaptcha_response.is_valid: + logging.info("Valid recaptcha from %s. Parameters were %r", + remote_ip, request.args) + else: + logging.info("Invalid recaptcha from %s. Parameters were %r", + remote_ip, request.args) + logging.info("Recaptcha error code: %s", recaptcha_response.error_code) + return self.render_GET(request) # redirect back to captcha + + return self.getBridgeRequestAnswer(request) + + def getBridgeRequestAnswer(self, request): + """ returns a response to a bridge request """ + + interval = self.schedule.getInterval(time.time()) + bridges = ( ) + ip = None + countryCode = None + if self.useForwardedHeader: + h = request.getHeader("X-Forwarded-For") + if h: + ip = h.split(",")[-1].strip() + if not bridgedb.Bridges.is_valid_ip(ip): + logging.warn("Got weird forwarded-for value %r",h) + ip = None + else: + ip = request.getClientIP() + + if geoip: + countryCode = geoip.country_code_by_addr(ip) + + # get locale + t = getLocaleFromRequest(request) + + format = request.args.get("format", None) + if format and len(format): format = format[0] # choose the first arg + + # do want any options? + transport = ipv6 = unblocked = False + + ipv6 = request.args.get("ipv6", False) + if ipv6: ipv6 = True # if anything after ?ipv6= + + try: + # validate method name + transport = re.match('[_a-zA-Z][_a-zA-Z0-9]*', + request.args.get("transport")[0]).group() + except (TypeError, IndexError, AttributeError): + transport = None + + try: + unblocked = re.match('[a-zA-Z]{2,4}', + request.args.get("unblocked")[0]).group() + except (TypeError, IndexError, AttributeError): + unblocked = False + + rules = [] + + if ip: + if ipv6: + rules.append(filterBridgesByIP6) + addressClass = IPv6Address + else: + rules.append(filterBridgesByIP4) + addressClass = IPv4Address + + if transport: + #XXX: A cleaner solution would differentiate between + # addresses by protocol rather than have separate lists + # Tor to be a transport, and selecting between them. + rules = [filterBridgesByTransport(transport, addressClass)] + + if unblocked: + rules.append(filterBridgesByNotBlockedIn(unblocked, + addressClass, transport)) + + bridges = self.distributor.getBridgesForIP(ip, interval, + self.nBridgesToGive, + countryCode, + bridgeFilterRules=rules) + + if bridges: + answer = "".join(" %s\n" % b.getConfigLine( + includeFingerprint=self.includeFingerprints, + addressClass=addressClass, + transport=transport, + request=bridgedb.Dist.uniformMap(ip) + ) for b in bridges) + else: + answer = t.gettext(I18n.BRIDGEDB_TEXT[7]) + + logging.info("Replying to web request from %s. Parameters were %r", ip, + request.args) + if format == 'plain': + request.setHeader("Content-Type", "text/plain") + return answer + else: + HTML_MESSAGE_TEMPLATE = self.buildHTMLMessageTemplate(t) + return HTML_MESSAGE_TEMPLATE % answer + + def buildHTMLMessageTemplate(self, t): + """DOCDOC""" + if self.domains: + email_domain_list = "<ul>" \ + + "".join(("<li>%s</li>"%d for d in self.domains)) + "</ul>" + else: + email_domain_list = "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[8]) + "</p>" + html_msg = "<html><head>"\ + + "<meta http-equiv=\"Content-Type\" content=\"text/html;"\ + + " charset=UTF-8\"/>" \ + + "</head><body>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[0]) \ + + "<pre id=\"bridges\">" \ + + "%s" \ + + "</pre></p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "</p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[2]) + "</p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[3]) + "</p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[4]) + "</p>" \ + + email_domain_list \ + + "<hr /><p><a href='?ipv6=true'>" \ + + t.gettext(I18n.BRIDGEDB_TEXT[20]) + "</a></p>" \ + + "<p><a href='?transport=obfs2'>" \ + + t.gettext(I18n.BRIDGEDB_TEXT[21]) + "</a></p>" \ + + "<form method='GET'>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[22]) + "</p>" \ + + "<input name='transport'>" \ + + "<input type='submit' value='" \ + + t.gettext(I18n.BRIDGEDB_TEXT[23]) +"'>" \ + + "</form>" \ + + "</body></html>" + return html_msg + + def buildHTMLMessageTemplateWithCaptcha(self, t, challenge, img): + """Builds a translated html response with recaptcha""" + if self.domains: + email_domain_list = "<ul>" \ + + "".join(("<li>%s</li>"%d for d in self.domains)) + "</ul>" + else: + email_domain_list = "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[8]) + "</p>" + + recaptchaTemplate = textwrap.dedent("""\ + <form action="" method="POST"> + <input type="hidden" name="recaptcha_challenge_field" + id="recaptcha_challenge_field"\ + value="{recaptchaChallengeField}"> + <img width="300" height="57" alt="{bridgeDBText14}"\ + src="{recaptchaImgSrc}"> + <div class="recaptcha_input_area"> + <label for="recaptcha_response_field">{bridgeDBText12}</label> + </div> + <div> + <input name="recaptcha_response_field"\ + id="recaptcha_response_field" + type="text" autocomplete="off"> + </div> + <div> + <input type="submit" name="submit" value="{bridgeDBText13}"> + </div> + </form> + """).strip() + + recaptchaTemplate = recaptchaTemplate.format( + recaptchaChallengeField=challenge, + recaptchaImgSrc=img, + bridgeDBText12=t.gettext(I18n.BRIDGEDB_TEXT[13]), + bridgeDBText13=t.gettext(I18n.BRIDGEDB_TEXT[14]), + bridgeDBText14=t.gettext(I18n.BRIDGEDB_TEXT[15])) + + html_msg = "<html><body>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "</p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[9]) + "</p>" \ + + "<p>" + recaptchaTemplate + "</p>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[4]) + "</p>" \ + + email_domain_list \ + + "<hr /><p><a href='?ipv6=true'>" \ + + t.gettext(I18n.BRIDGEDB_TEXT[20]) + "</a></p>" \ + + "<p><a href='?transport=obfs2'>" \ + + t.gettext(I18n.BRIDGEDB_TEXT[21]) + "</a></p>" \ + + "<form method='GET'>" \ + + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[22]) + "</p>" \ + + "<input name='transport'>" \ + + "<input name='submit' type='submit'>" \ + + "</form>" \ + + "</body></html>" + return html_msg + +def addWebServer(cfg, dist, sched): + """Set up a web server. + cfg -- a configuration object from Main. We use these options: + HTTPS_N_BRIDGES_PER_ANSWER + HTTP_UNENCRYPTED_PORT + HTTP_UNENCRYPTED_BIND_IP + HTTP_USE_IP_FROM_FORWARDED_HEADER + HTTPS_PORT + HTTPS_BIND_IP + HTTPS_USE_IP_FROM_FORWARDED_HEADER + RECAPTCHA_ENABLED + RECAPTCHA_PUB_KEY + RECAPTCHA_PRIV_KEY + dist -- an IPBasedDistributor object. + sched -- an IntervalSchedule object. + """ + Site = twisted.web.server.Site + site = None + if cfg.HTTP_UNENCRYPTED_PORT: + ip = cfg.HTTP_UNENCRYPTED_BIND_IP or "" + resource = WebResource(dist, sched, cfg.HTTPS_N_BRIDGES_PER_ANSWER, + cfg.HTTP_USE_IP_FROM_FORWARDED_HEADER, + includeFingerprints=cfg.HTTPS_INCLUDE_FINGERPRINTS, + useRecaptcha=cfg.RECAPTCHA_ENABLED, + domains=cfg.EMAIL_DOMAINS, + recaptchaPrivKey=cfg.RECAPTCHA_PRIV_KEY, + recaptchaPubKey=cfg.RECAPTCHA_PUB_KEY) + site = Site(resource) + reactor.listenTCP(cfg.HTTP_UNENCRYPTED_PORT, site, interface=ip) + if cfg.HTTPS_PORT: + from twisted.internet.ssl import DefaultOpenSSLContextFactory + #from OpenSSL.SSL import SSLv3_METHOD + ip = cfg.HTTPS_BIND_IP or "" + factory = DefaultOpenSSLContextFactory(cfg.HTTPS_KEY_FILE, + cfg.HTTPS_CERT_FILE) + resource = WebResource(dist, sched, cfg.HTTPS_N_BRIDGES_PER_ANSWER, + cfg.HTTPS_USE_IP_FROM_FORWARDED_HEADER, + includeFingerprints=cfg.HTTPS_INCLUDE_FINGERPRINTS, + domains=cfg.EMAIL_DOMAINS, + useRecaptcha=cfg.RECAPTCHA_ENABLED, + recaptchaPrivKey=cfg.RECAPTCHA_PRIV_KEY, + recaptchaPubKey=cfg.RECAPTCHA_PUB_KEY) + site = Site(resource) + reactor.listenSSL(cfg.HTTPS_PORT, site, factory, interface=ip) + return site + +def runServers(): + """Start all the servers that we've configured. Exits when they do.""" + reactor.run() + +def getLocaleFromRequest(request): + # See if we did get a request for a certain locale, otherwise fall back + # to 'en': + # Try evaluating the path /foo first, then check if we got a ?lang=foo + default_lang = lang = "en" + if len(request.path) > 1: + lang = request.path[1:] + if lang == default_lang: + lang = request.args.get("lang", [default_lang]) + lang = lang[0] + return I18n.getLang(lang) diff --git a/lib/bridgedb/Server.py b/lib/bridgedb/Server.py deleted file mode 100644 index 86a402e..0000000 --- a/lib/bridgedb/Server.py +++ /dev/null @@ -1,809 +0,0 @@ -# BridgeDB by Nick Mathewson. -# Copyright (c) 2007-2009, The Tor Project, Inc. -# See LICENSE for licensing information - -""" -This module implements the web and email interfaces to the bridge database. -""" - -from StringIO import StringIO -import MimeWriter -import rfc822 -import time -import logging -import gettext -import re - -from zope.interface import implements - -from twisted.internet import reactor -from twisted.internet.defer import Deferred -from twisted.internet.task import LoopingCall -import twisted.web.resource -import twisted.web.server -import twisted.mail.smtp - -import bridgedb.Dist -import bridgedb.I18n as I18n - -import recaptcha.client.captcha as captcha -from random import randint -from bridgedb.Raptcha import Raptcha -import base64 -import textwrap - -from ipaddr import IPv4Address, IPv6Address -from bridgedb.Dist import BadEmail, TooSoonEmail, IgnoreEmail - -from bridgedb.Filters import filterBridgesByIP6 -from bridgedb.Filters import filterBridgesByIP4 -from bridgedb.Filters import filterBridgesByTransport -from bridgedb.Filters import filterBridgesByNotBlockedIn - -import gpgme - -try: - import GeoIP - # GeoIP data object: choose database here - # This is the same geoip implementation that pytorctl uses - geoip = GeoIP.new(GeoIP.GEOIP_STANDARD) - logging.info("GeoIP database loaded") -except: - geoip = None - logging.warn("GeoIP database not found") - -class WebResource(twisted.web.resource.Resource): - """This resource is used by Twisted Web to give a web page with some - bridges in response to a request.""" - isLeaf = True - - def __init__(self, distributor, schedule, N=1, useForwardedHeader=False, - includeFingerprints=True, - useRecaptcha=False,recaptchaPrivKey='', recaptchaPubKey='', - domains=None): - """Create a new WebResource. - distributor -- an IPBasedDistributor object - schedule -- an IntervalSchedule object - N -- the number of bridges to hand out per query. - """ - gettext.install("bridgedb", unicode=True) - twisted.web.resource.Resource.__init__(self) - self.distributor = distributor - self.schedule = schedule - self.nBridgesToGive = N - self.useForwardedHeader = useForwardedHeader - self.includeFingerprints = includeFingerprints - - # do not use mutable types as __init__ defaults! - if not domains: domains = [] - self.domains = domains - - # recaptcha options - self.useRecaptcha = useRecaptcha - self.recaptchaPrivKey = recaptchaPrivKey - self.recaptchaPubKey = recaptchaPubKey - - def render_GET(self, request): - if self.useRecaptcha: - # get a captcha - c = Raptcha(self.recaptchaPubKey, self.recaptchaPrivKey) - c.get() - - # TODO: this does not work for versions of IE < 8.0 - imgstr = 'data:image/jpeg;base64,%s' % base64.b64encode(c.image) - HTML_CAPTCHA_TEMPLATE = self.buildHTMLMessageTemplateWithCaptcha( - getLocaleFromRequest(request), c.challenge, imgstr) - return HTML_CAPTCHA_TEMPLATE - else: - return self.getBridgeRequestAnswer(request) - - - def render_POST(self, request): - - # check captcha if recaptcha support is enabled - if self.useRecaptcha: - try: - challenge = request.args['recaptcha_challenge_field'][0] - response = request.args['recaptcha_response_field'][0] - - except: - return self.render_GET(request) - - # generate a random IP for the captcha submission - remote_ip = '%d.%d.%d.%d' % (randint(1,255),randint(1,255), - randint(1,255),randint(1,255)) - - recaptcha_response = captcha.submit(challenge, response, - self.recaptchaPrivKey, remote_ip) - if recaptcha_response.is_valid: - logging.info("Valid recaptcha from %s. Parameters were %r", - remote_ip, request.args) - else: - logging.info("Invalid recaptcha from %s. Parameters were %r", - remote_ip, request.args) - logging.info("Recaptcha error code: %s", recaptcha_response.error_code) - return self.render_GET(request) # redirect back to captcha - - return self.getBridgeRequestAnswer(request) - - def getBridgeRequestAnswer(self, request): - """ returns a response to a bridge request """ - - interval = self.schedule.getInterval(time.time()) - bridges = ( ) - ip = None - countryCode = None - if self.useForwardedHeader: - h = request.getHeader("X-Forwarded-For") - if h: - ip = h.split(",")[-1].strip() - if not bridgedb.Bridges.is_valid_ip(ip): - logging.warn("Got weird forwarded-for value %r",h) - ip = None - else: - ip = request.getClientIP() - - if geoip: - countryCode = geoip.country_code_by_addr(ip) - - # get locale - t = getLocaleFromRequest(request) - - format = request.args.get("format", None) - if format and len(format): format = format[0] # choose the first arg - - # do want any options? - transport = ipv6 = unblocked = False - - ipv6 = request.args.get("ipv6", False) - if ipv6: ipv6 = True # if anything after ?ipv6= - - try: - # validate method name - transport = re.match('[_a-zA-Z][_a-zA-Z0-9]*', - request.args.get("transport")[0]).group() - except (TypeError, IndexError, AttributeError): - transport = None - - try: - unblocked = re.match('[a-zA-Z]{2,4}', - request.args.get("unblocked")[0]).group() - except (TypeError, IndexError, AttributeError): - unblocked = False - - rules = [] - - if ip: - if ipv6: - rules.append(filterBridgesByIP6) - addressClass = IPv6Address - else: - rules.append(filterBridgesByIP4) - addressClass = IPv4Address - - if transport: - #XXX: A cleaner solution would differentiate between - # addresses by protocol rather than have separate lists - # Tor to be a transport, and selecting between them. - rules = [filterBridgesByTransport(transport, addressClass)] - - if unblocked: - rules.append(filterBridgesByNotBlockedIn(unblocked, - addressClass, transport)) - - bridges = self.distributor.getBridgesForIP(ip, interval, - self.nBridgesToGive, - countryCode, - bridgeFilterRules=rules) - - if bridges: - answer = "".join(" %s\n" % b.getConfigLine( - includeFingerprint=self.includeFingerprints, - addressClass=addressClass, - transport=transport, - request=bridgedb.Dist.uniformMap(ip) - ) for b in bridges) - else: - answer = t.gettext(I18n.BRIDGEDB_TEXT[7]) - - logging.info("Replying to web request from %s. Parameters were %r", ip, - request.args) - if format == 'plain': - request.setHeader("Content-Type", "text/plain") - return answer - else: - HTML_MESSAGE_TEMPLATE = self.buildHTMLMessageTemplate(t) - return HTML_MESSAGE_TEMPLATE % answer - - def buildHTMLMessageTemplate(self, t): - """DOCDOC""" - if self.domains: - email_domain_list = "<ul>" \ - + "".join(("<li>%s</li>"%d for d in self.domains)) + "</ul>" - else: - email_domain_list = "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[8]) + "</p>" - html_msg = "<html><head>"\ - + "<meta http-equiv=\"Content-Type\" content=\"text/html;"\ - + " charset=UTF-8\"/>" \ - + "</head><body>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[0]) \ - + "<pre id=\"bridges\">" \ - + "%s" \ - + "</pre></p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "</p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[2]) + "</p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[3]) + "</p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[4]) + "</p>" \ - + email_domain_list \ - + "<hr /><p><a href='?ipv6=true'>" \ - + t.gettext(I18n.BRIDGEDB_TEXT[20]) + "</a></p>" \ - + "<p><a href='?transport=obfs2'>" \ - + t.gettext(I18n.BRIDGEDB_TEXT[21]) + "</a></p>" \ - + "<form method='GET'>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[22]) + "</p>" \ - + "<input name='transport'>" \ - + "<input type='submit' value='" \ - + t.gettext(I18n.BRIDGEDB_TEXT[23]) +"'>" \ - + "</form>" \ - + "</body></html>" - return html_msg - - def buildHTMLMessageTemplateWithCaptcha(self, t, challenge, img): - """Builds a translated html response with recaptcha""" - if self.domains: - email_domain_list = "<ul>" \ - + "".join(("<li>%s</li>"%d for d in self.domains)) + "</ul>" - else: - email_domain_list = "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[8]) + "</p>" - - recaptchaTemplate = textwrap.dedent("""\ - <form action="" method="POST"> - <input type="hidden" name="recaptcha_challenge_field" - id="recaptcha_challenge_field"\ - value="{recaptchaChallengeField}"> - <img width="300" height="57" alt="{bridgeDBText14}"\ - src="{recaptchaImgSrc}"> - <div class="recaptcha_input_area"> - <label for="recaptcha_response_field">{bridgeDBText12}</label> - </div> - <div> - <input name="recaptcha_response_field"\ - id="recaptcha_response_field" - type="text" autocomplete="off"> - </div> - <div> - <input type="submit" name="submit" value="{bridgeDBText13}"> - </div> - </form> - """).strip() - - recaptchaTemplate = recaptchaTemplate.format( - recaptchaChallengeField=challenge, - recaptchaImgSrc=img, - bridgeDBText12=t.gettext(I18n.BRIDGEDB_TEXT[13]), - bridgeDBText13=t.gettext(I18n.BRIDGEDB_TEXT[14]), - bridgeDBText14=t.gettext(I18n.BRIDGEDB_TEXT[15])) - - html_msg = "<html><body>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "</p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[9]) + "</p>" \ - + "<p>" + recaptchaTemplate + "</p>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[4]) + "</p>" \ - + email_domain_list \ - + "<hr /><p><a href='?ipv6=true'>" \ - + t.gettext(I18n.BRIDGEDB_TEXT[20]) + "</a></p>" \ - + "<p><a href='?transport=obfs2'>" \ - + t.gettext(I18n.BRIDGEDB_TEXT[21]) + "</a></p>" \ - + "<form method='GET'>" \ - + "<p>" + t.gettext(I18n.BRIDGEDB_TEXT[22]) + "</p>" \ - + "<input name='transport'>" \ - + "<input name='submit' type='submit'>" \ - + "</form>" \ - + "</body></html>" - return html_msg - -def addWebServer(cfg, dist, sched): - """Set up a web server. - cfg -- a configuration object from Main. We use these options: - HTTPS_N_BRIDGES_PER_ANSWER - HTTP_UNENCRYPTED_PORT - HTTP_UNENCRYPTED_BIND_IP - HTTP_USE_IP_FROM_FORWARDED_HEADER - HTTPS_PORT - HTTPS_BIND_IP - HTTPS_USE_IP_FROM_FORWARDED_HEADER - RECAPTCHA_ENABLED - RECAPTCHA_PUB_KEY - RECAPTCHA_PRIV_KEY - dist -- an IPBasedDistributor object. - sched -- an IntervalSchedule object. - """ - Site = twisted.web.server.Site - site = None - if cfg.HTTP_UNENCRYPTED_PORT: - ip = cfg.HTTP_UNENCRYPTED_BIND_IP or "" - resource = WebResource(dist, sched, cfg.HTTPS_N_BRIDGES_PER_ANSWER, - cfg.HTTP_USE_IP_FROM_FORWARDED_HEADER, - includeFingerprints=cfg.HTTPS_INCLUDE_FINGERPRINTS, - useRecaptcha=cfg.RECAPTCHA_ENABLED, - domains=cfg.EMAIL_DOMAINS, - recaptchaPrivKey=cfg.RECAPTCHA_PRIV_KEY, - recaptchaPubKey=cfg.RECAPTCHA_PUB_KEY) - site = Site(resource) - reactor.listenTCP(cfg.HTTP_UNENCRYPTED_PORT, site, interface=ip) - if cfg.HTTPS_PORT: - from twisted.internet.ssl import DefaultOpenSSLContextFactory - #from OpenSSL.SSL import SSLv3_METHOD - ip = cfg.HTTPS_BIND_IP or "" - factory = DefaultOpenSSLContextFactory(cfg.HTTPS_KEY_FILE, - cfg.HTTPS_CERT_FILE) - resource = WebResource(dist, sched, cfg.HTTPS_N_BRIDGES_PER_ANSWER, - cfg.HTTPS_USE_IP_FROM_FORWARDED_HEADER, - includeFingerprints=cfg.HTTPS_INCLUDE_FINGERPRINTS, - domains=cfg.EMAIL_DOMAINS, - useRecaptcha=cfg.RECAPTCHA_ENABLED, - recaptchaPrivKey=cfg.RECAPTCHA_PRIV_KEY, - recaptchaPubKey=cfg.RECAPTCHA_PUB_KEY) - site = Site(resource) - reactor.listenSSL(cfg.HTTPS_PORT, site, factory, interface=ip) - return site - -class MailFile: - """A file-like object used to hand rfc822.Message a list of lines - as though it were reading them from a file.""" - def __init__(self, lines): - self.lines = lines - self.idx = 0 - def readline(self): - try : - line = self.lines[self.idx] - self.idx += 1 - return line - except IndexError: - return "" - -def getBridgeDBEmailAddrFromList(ctx, address_list): - """Loop through a list of (full name, email address) pairs and look up our - mail address. If our address isn't found (which can't happen), return - the default ctx from address so we can keep on working. - """ - email = ctx.fromAddr - for _, address in address_list: - # Strip the @torproject.org part from the address - idx = address.find('@') - if idx != -1: - username = address[:idx] - # See if the user looks familiar. We do a 'find' instead - # of compare because we might have a '+' address here - if username.find(ctx.username) != -1: - email = address - return email - -def getMailResponse(lines, ctx): - """Given a list of lines from an incoming email message, and a - MailContext object, parse the email and decide what to do in response. - If we want to answer, return a 2-tuple containing the address that - will receive the response, and a readable filelike object containing - the response. Return None,None if we shouldn't answer. - """ - # Extract data from the headers. - msg = rfc822.Message(MailFile(lines)) - subject = msg.getheader("Subject", None) - if not subject: subject = "[no subject]" - clientFromAddr = msg.getaddr("From") - clientSenderAddr = msg.getaddr("Sender") - # RFC822 requires at least one 'To' address - clientToList = msg.getaddrlist("To") - clientToaddr = getBridgeDBEmailAddrFromList(ctx, clientToList) - msgID = msg.getheader("Message-ID", None) - if clientSenderAddr and clientSenderAddr[1]: - clientAddr = clientSenderAddr[1] - elif clientFromAddr and clientFromAddr[1]: - clientAddr = clientFromAddr[1] - else: - logging.info("No From or Sender header on incoming mail.") - return None,None - - # Look up the locale part in the 'To:' address, if there is one and get - # the appropriate Translation object - lang = getLocaleFromPlusAddr(clientToaddr) - t = I18n.getLang(lang) - - try: - _, addrdomain = bridgedb.Dist.extractAddrSpec(clientAddr.lower()) - except BadEmail: - logging.info("Ignoring bad address on incoming email.") - return None,None - if not addrdomain: - logging.info("Couldn't parse domain from %r", clientAddr) - if addrdomain and ctx.cfg.EMAIL_DOMAIN_MAP: - addrdomain = ctx.cfg.EMAIL_DOMAIN_MAP.get(addrdomain, addrdomain) - if addrdomain not in ctx.cfg.EMAIL_DOMAINS: - logging.info("Unrecognized email domain %r", addrdomain) - return None,None - rules = ctx.cfg.EMAIL_DOMAIN_RULES.get(addrdomain, []) - if 'dkim' in rules: - # getheader() returns the last of a given kind of header; we want - # to get the first, so we use getheaders() instead. - dkimHeaders = msg.getheaders("X-DKIM-Authentication-Results") - dkimHeader = "<no header>" - if dkimHeaders: dkimHeader = dkimHeaders[0] - if not dkimHeader.startswith("pass"): - logging.info("Got a bad dkim header (%r) on an incoming mail; " - "rejecting it.", dkimHeader) - return None, None - - # Was the magic string included - #for ln in lines: - # if ln.strip().lower() in ("get bridges", "subject: get bridges"): - # break - #else: - # logging.info("Got a mail from %r with no bridge request; dropping", - # clientAddr) - # return None,None - - # Figure out which bridges to send - unblocked = transport = ipv6 = skippedheaders = False - bridgeFilterRules = [] - addressClass = None - for ln in lines: - # ignore all lines before the subject header - if "subject" in ln.strip().lower(): - skippedheaders = True - if not skippedheaders: continue - - if "ipv6" in ln.strip().lower(): - ipv6 = True - if "transport" in ln.strip().lower(): - try: - transport = re.search("transport ([_a-zA-Z][_a-zA-Z0-9]*)", - ln).group(1).strip() - except (TypeError, AttributeError): - transport = None - logging.debug("Got request for transport: %s" % transport) - if "unblocked" in ln.strip().lower(): - try: - unblocked = re.search("unblocked ([a-zA-Z]{2,4})", - ln).group(1).strip() - except (TypeError, AttributeError): - transport = None - - if ipv6: - bridgeFilterRules.append(filterBridgesByIP6) - addressClass = IPv6Address - else: - bridgeFilterRules.append(filterBridgesByIP4) - addressClass = IPv4Address - - if transport: - bridgeFilterRules = [filterBridgesByTransport(transport, addressClass)] - - if unblocked: - rules.append(filterBridgesByNotBlockedIn(unblocked, - addressClass, transport)) - - try: - interval = ctx.schedule.getInterval(time.time()) - bridges = ctx.distributor.getBridgesForEmail(clientAddr, - interval, ctx.N, - countryCode=None, - bridgeFilterRules=bridgeFilterRules) - - # Handle rate limited email - except TooSoonEmail, e: - logging.info("Got a mail too frequently; warning %r: %s.", - clientAddr, e) - - # Compose a warning email - # MAX_EMAIL_RATE is in seconds, convert to hours - body = buildSpamWarningTemplate(t) % (bridgedb.Dist.MAX_EMAIL_RATE / 3600) - return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID, - gpgContext=ctx.gpgContext) - - except IgnoreEmail, e: - logging.info("Got a mail too frequently; ignoring %r: %s.", - clientAddr, e) - return None, None - - except BadEmail, e: - logging.info("Got a mail from a bad email address %r: %s.", - clientAddr, e) - return None, None - - if bridges: - with_fp = ctx.cfg.EMAIL_INCLUDE_FINGERPRINTS - answer = "".join(" %s\n" %b.getConfigLine( - includeFingerprint=with_fp, - addressClass=addressClass, - transport=transport, - request=clientAddr - ) for b in bridges) - else: - answer = "(no bridges currently available)" - - body = buildMessageTemplate(t) % answer - # Generate the message. - return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID, - gpgContext=ctx.gpgContext) - -def buildMessageTemplate(t): - msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[0]) + "\n\n" \ - + "%s\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[2]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[3]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[17])+ "\n\n" - # list supported commands, e.g. ipv6, transport - msg_template = msg_template \ - + " " + t.gettext(I18n.BRIDGEDB_TEXT[18])+ "\n" \ - + " " + t.gettext(I18n.BRIDGEDB_TEXT[19])+ "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[6]) + "\n\n" - return msg_template - -def buildSpamWarningTemplate(t): - msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[10]) + "\n\n" \ - + "%s " \ - + t.gettext(I18n.BRIDGEDB_TEXT[11]) + "\n\n" \ - + t.gettext(I18n.BRIDGEDB_TEXT[12]) + "\n\n" - return msg_template - -def replyToMail(lines, ctx): - """Given a list of lines from an incoming email message, and a - MailContext object, possibly send a reply. - """ - logging.info("Got a completed email; deciding whether to reply.") - sendToUser, response = getMailResponse(lines, ctx) - if response is None: - logging.debug("getMailResponse said not to reply, so I won't.") - return - response.seek(0) - d = Deferred() - factory = twisted.mail.smtp.SMTPSenderFactory( - ctx.smtpFromAddr, - sendToUser, - response, - d) - reactor.connectTCP(ctx.smtpServer, ctx.smtpPort, factory) - logging.info("Sending reply to %r", sendToUser) - return d - -def getLocaleFromPlusAddr(address): - """See whether the user sent his email to a 'plus' address, for - instance to bridgedb+fa@tpo. Plus addresses are the current - mechanism to set the reply language - """ - replyLocale = "en" - r = '.*(<)?(\w+\+(\w+)@\w+(?:\.\w+)+)(?(1)>)' - match = re.match(r, address) - if match: - replyLocale = match.group(3) - - return replyLocale - -def getLocaleFromRequest(request): - # See if we did get a request for a certain locale, otherwise fall back - # to 'en': - # Try evaluating the path /foo first, then check if we got a ?lang=foo - default_lang = lang = "en" - if len(request.path) > 1: - lang = request.path[1:] - if lang == default_lang: - lang = request.args.get("lang", [default_lang]) - lang = lang[0] - return I18n.getLang(lang) - -class MailContext: - """Helper object that holds information used by email subsystem.""" - def __init__(self, cfg, dist, sched): - # Reject any RCPT TO lines that aren't to this user. - self.username = (cfg.EMAIL_USERNAME or - "bridges") - # Reject any mail longer than this. - self.maximumSize = 32*1024 - # Use this server for outgoing mail. - self.smtpServer = (cfg.EMAIL_SMTP_HOST or "127.0.0.1") - self.smtpPort = (cfg.EMAIL_SMTP_PORT or 25) - # Use this address in the MAIL FROM line for outgoing mail. - self.smtpFromAddr = (cfg.EMAIL_SMTP_FROM_ADDR or - "bridges@torproject.org") - # Use this address in the "From:" header for outgoing mail. - self.fromAddr = (cfg.EMAIL_FROM_ADDR or - "bridges@torproject.org") - # An EmailBasedDistributor object - self.distributor = dist - # An IntervalSchedule object - self.schedule = sched - # The number of bridges to send for each email. - self.N = cfg.EMAIL_N_BRIDGES_PER_ANSWER - - # Initialize a gpg context or set to None for backward compatibliity. - self.gpgContext = getGPGContext(cfg) - - self.cfg = cfg - -class MailMessage: - """Plugs into the Twisted Mail and receives an incoming message. - Once the message is in, we reply or we don't. """ - implements(twisted.mail.smtp.IMessage) - - def __init__(self, ctx): - """Create a new MailMessage from a MailContext.""" - self.ctx = ctx - self.lines = [] - self.nBytes = 0 - self.ignoring = False - - def lineReceived(self, line): - """Called when we get another line of an incoming message.""" - self.nBytes += len(line) - logging.debug("> %s", line.rstrip("\r\n")) - if self.nBytes > self.ctx.maximumSize: - self.ignoring = True - else: - self.lines.append(line) - - def eomReceived(self): - """Called when we receive the end of a message.""" - if not self.ignoring: - replyToMail(self.lines, self.ctx) - return twisted.internet.defer.succeed(None) - - def connectionLost(self): - """Called if we die partway through reading a message.""" - pass - -class MailDelivery: - """Plugs into Twisted Mail and handles SMTP commands.""" - implements(twisted.mail.smtp.IMessageDelivery) - def setBridgeDBContext(self, ctx): - self.ctx = ctx - def receivedHeader(self, helo, origin, recipients): - #XXXX what is this for? what should it be? - return "Received: BridgeDB" - def validateFrom(self, helo, origin): - return origin - def validateTo(self, user): - """If the local user that was addressed isn't our configured local - user or doesn't contain a '+' with a prefix matching the local - configured user: Yell - """ - u = user.dest.local - # Hasplus? If yes, strip '+foo' - idx = u.find('+') - if idx != -1: - u = u[:idx] - if u != self.ctx.username: - raise twisted.mail.smtp.SMTPBadRcpt(user) - return lambda: MailMessage(self.ctx) - -class MailFactory(twisted.mail.smtp.SMTPFactory): - """Plugs into Twisted Mail; creates a new MailDelivery whenever we get - a connection on the SMTP port.""" - def __init__(self, *a, **kw): - twisted.mail.smtp.SMTPFactory.__init__(self, *a, **kw) - self.delivery = MailDelivery() - - def setBridgeDBContext(self, ctx): - self.ctx = ctx - self.delivery.setBridgeDBContext(ctx) - - def buildProtocol(self, addr): - p = twisted.mail.smtp.SMTPFactory.buildProtocol(self, addr) - p.delivery = self.delivery - return p - -def addSMTPServer(cfg, dist, sched): - """Set up a smtp server. - cfg -- a configuration object from Main. We use these options: - EMAIL_BIND_IP - EMAIL_PORT - EMAIL_N_BRIDGES_PER_ANSWER - EMAIL_DOMAIN_RULES - dist -- an EmailBasedDistributor object. - sched -- an IntervalSchedule object. - """ - ctx = MailContext(cfg, dist, sched) - factory = MailFactory() - factory.setBridgeDBContext(ctx) - ip = cfg.EMAIL_BIND_IP or "" - reactor.listenTCP(cfg.EMAIL_PORT, factory, interface=ip) - # Set up a LoopingCall to run every 30 minutes and forget old email times. - lc = LoopingCall(dist.cleanDatabase) - lc.start(1800, now=False) - return factory - -def runServers(): - """Start all the servers that we've configured. Exits when they do.""" - reactor.run() - -def getLocaleFromRequest(request): - # See if we did get a request for a certain locale, otherwise fall back - # to 'en': - # Try evaluating the path /foo first, then check if we got a ?lang=foo - default_lang = lang = "en" - if len(request.path) > 1: - lang = request.path[1:] - if lang == default_lang: - lang = request.args.get("lang", [default_lang]) - lang = lang[0] - return I18n.getLang(lang) - -def composeEmail(fromAddr, clientAddr, subject, body, msgID=False, - gpgContext=None): - - f = StringIO() - w = MimeWriter.MimeWriter(f) - w.addheader("From", fromAddr) - w.addheader("To", clientAddr) - w.addheader("Message-ID", twisted.mail.smtp.messageid()) - if not subject.startswith("Re:"): subject = "Re: %s"%subject - w.addheader("Subject", subject) - if msgID: - w.addheader("In-Reply-To", msgID) - w.addheader("Date", twisted.mail.smtp.rfc822date()) - mailbody = w.startbody("text/plain") - - # gpg-clearsign messages - if gpgContext: - signature = StringIO() - plaintext = StringIO(body) - sigs = gpgContext.sign(plaintext, signature, gpgme.SIG_MODE_CLEAR) - if (len(sigs) != 1): - logging.warn('Failed to sign message!') - signature.seek(0) - [mailbody.write(l) for l in signature] - else: - mailbody.write(body) - - f.seek(0) - logging.debug("Email body:\n%s" % f.read()) - f.seek(0) - return clientAddr, f - -def getGPGContext(cfg): - """ Returns a gpgme Context() with the signers initialized by the keyfile - specified by the option EMAIL_GPG_SIGNING_KEY in bridgedb.conf, or None - if the option was not enabled or unable to initialize. - - The key should not be protected by a passphrase. - """ - try: - # must have enabled signing and specified a key file - if not cfg.EMAIL_GPG_SIGNING_ENABLED or not cfg.EMAIL_GPG_SIGNING_KEY: - return None - except AttributeError: - return None - - try: - # import the key - keyfile = open(cfg.EMAIL_GPG_SIGNING_KEY) - logging.debug("Opened GPG Keyfile %s" % cfg.EMAIL_GPG_SIGNING_KEY) - ctx = gpgme.Context() - result = ctx.import_(keyfile) - - assert len(result.imports) == 1 - fingerprint = result.imports[0][0] - keyfile.close() - logging.debug("GPG Key with fingerprint %s imported" % fingerprint) - - ctx.armor = True - ctx.signers = [ctx.get_key(fingerprint)] - assert len(ctx.signers) == 1 - - # make sure we can sign - message = StringIO('Test') - signature = StringIO() - new_sigs = ctx.sign(message, signature, gpgme.SIG_MODE_CLEAR) - assert len(new_sigs) == 1 - - # return the ctx - return ctx - - except IOError, e: - # exit noisily if keyfile not found - exit(e) - except AssertionError: - # exit noisily if key does not pass tests - exit('Invalid GPG Signing Key')
participants (1)
-
aagbsn@torproject.org