Merge branch 'release-0.11.0'
[tor-bridgedb.git] / bridgedb / distributors / moat / server.py
blob7e83d94cfe778879b0200f7e5b97d679bc70f0a7
1 # -*- coding: utf-8 ; test-case-name: bridgedb.test.test_distributors_moat_server -*-
3 # This file is part of BridgeDB, a Tor bridge distribution system.
5 # :authors: please see included AUTHORS file
6 # :copyright: (c) 2017, The Tor Project, Inc.
7 # (c) 2017, Isis Lovecruft
8 # :license: see LICENSE for licensing information
10 """
11 .. py:module:: bridgedb.distributors.moat.server
12 :synopsis: Server which implements JSON API to interface with Tor Browser
13 clients through a meek tunnel.
15 bridgedb.distributors.moat.server
16 =================================
18 Server which implements JSON API to interface with Tor Browser clients through a
19 meek tunnel.
21 .. inheritance-diagram:: JsonAPIResource JsonAPIErrorResource CustomErrorHandlingResource JsonAPIDataResource CaptchaResource CaptchaCheckResource CaptchaFetchResource
22 :parts: 1
23 """
25 from __future__ import print_function
27 import base64
28 import json
29 import logging
30 import time
32 from functools import partial
34 from ipaddr import IPAddress
36 from twisted.internet import reactor
37 from twisted.internet.error import CannotListenError
38 from twisted.web import resource
39 from twisted.web.server import Site
41 from bridgedb import metrics
42 from bridgedb import captcha
43 from bridgedb import crypto
44 from bridgedb import antibot
45 from bridgedb.distributors.common.http import setFQDN
46 from bridgedb.distributors.common.http import getFQDN
47 from bridgedb.distributors.common.http import getClientIP
48 from bridgedb.distributors.moat.request import MoatBridgeRequest
49 from bridgedb.qrcodes import generateQR
50 from bridgedb.schedule import Unscheduled
51 from bridgedb.schedule import ScheduledInterval
52 from bridgedb.util import replaceControlChars
54 # We use our metrics singletons to keep track of BridgeDB metrics such as
55 # "number of failed HTTPS bridge requests."
56 moatMetrix = metrics.MoatMetrics()
57 internalMetrix = metrics.InternalMetrics()
60 #: The current version of the moat JSON API that we speak
61 MOAT_API_VERSION = '0.1.0'
63 #: The root path to resources for the moat server
64 SERVER_PUBLIC_ROOT = None
66 #: An ordered list of the preferred transports which moat should
67 #: distribute, in order from most preferable to least preferable.
68 TRANSPORT_PREFERENCE_LIST = None
70 #: All of the pluggable transports BridgeDB currently supports.
71 SUPPORTED_TRANSPORTS = None
74 def getFQDNAndRoot():
75 """Get the server's public FQDN plus the root directory for the web server.
76 """
77 root = getRoot()
78 fqdn = getFQDN()
80 if not root.startswith('/') and not fqdn.endswith('/'):
81 return '/'.join([fqdn, root])
82 else:
83 return ''.join([fqdn, root])
85 def setRoot(root):
86 """Set the global :data:`SERVER_PUBLIC_ROOT` variable.
88 :param str root: The path to the root directory for the web server.
89 """
90 logging.info("Setting Moat server public root to %r" % root)
92 global SERVER_PUBLIC_ROOT
93 SERVER_PUBLIC_ROOT = root
95 def getRoot():
96 """Get the setting for the HTTP server's public FQDN from the global
97 :data:`SERVER_PUBLIC_FQDN variable.
99 :rtype: str or None
101 return SERVER_PUBLIC_ROOT
103 def setPreferredTransports(preferences):
104 """Set the global ``TRANSPORT_PREFERENCE_LIST``."""
105 global TRANSPORT_PREFERENCE_LIST
106 TRANSPORT_PREFERENCE_LIST = preferences
108 def getPreferredTransports():
109 """Get the global ``TRANSPORT_PREFERENCE_LIST``.
111 :rtype: list
112 :returns: A list of preferences for which pluggable transports to distribute
113 to moat clients.
115 return TRANSPORT_PREFERENCE_LIST
117 def setSupportedTransports(transports):
118 """Set the global ``SUPPORTED_TRANSPORTS``.
120 :param dist transports: The ``SUPPORTED_TRANSPORTS`` dict from a
121 bridgedb.conf file.
123 supported = [k for (k, v) in transports.items() if v]
125 if not "vanilla" in supported:
126 supported.append("vanilla")
128 global SUPPORTED_TRANSPORTS
129 SUPPORTED_TRANSPORTS = supported
131 def getSupportedTransports():
132 """Get the global ``SUPPORTED_TRANSPORTS``.
134 :rtype: list
135 :returns: A list all pluggable transports we support.
137 return SUPPORTED_TRANSPORTS
140 class JsonAPIResource(resource.Resource):
141 """A resource which conforms to the `JSON API spec <http://jsonapi.org/>`__.
144 def __init__(self, useForwardedHeader=True, skipLoopback=False):
145 """Create a JSON API resource, containing either error(s) or data.
147 :param bool useForwardedHeader: If ``True``, obtain the client's IP
148 address from the ``X-Forwarded-For`` HTTP header.
149 :param bool skipLoopback: Skip loopback addresses when parsing the
150 X-Forwarded-For header.
152 resource.Resource.__init__(self)
153 self.useForwardedHeader = useForwardedHeader
154 self.skipLoopback = skipLoopback
156 def getClientIP(self, request):
157 """Get the client's IP address from the ``'X-Forwarded-For:'``
158 header, or from the :api:`request <twisted.web.server.Request>`.
160 :type request: :api:`twisted.web.http.Request`
161 :param request: A ``Request`` for a
162 :api:`twisted.web.resource.Resource`.
163 :rtype: ``None`` or :any:`str`
164 :returns: The client's IP address, if it was obtainable.
166 return getClientIP(request, self.useForwardedHeader, self.skipLoopback)
168 def formatDataForResponse(self, data, request):
169 """Format a dictionary of ``data`` into JSON and add necessary response
170 headers.
172 This method will set the appropriate response headers:
173 * `Content-Type: application/vnd.api+json`
174 * `Server: moat/VERSION`
176 :type data: dict
177 :param data: Some data to respond with. This will be formatted as JSON.
178 :type request: :api:`twisted.web.http.Request`
179 :param request: A ``Request`` for a :api:`twisted.web.resource.Resource`.
180 :returns: The encoded data.
182 request.responseHeaders.addRawHeader("Content-Type", "application/vnd.api+json")
183 request.responseHeaders.addRawHeader("Server", "moat/%s" % MOAT_API_VERSION)
185 if data:
186 rendered = json.dumps(data).encode("utf-8")
187 else:
188 rendered = b""
190 return rendered
193 class JsonAPIErrorResource(JsonAPIResource):
194 """A JSON API resource which explains that some error has occured."""
196 isLeaf = True
198 def __init__(self, id=0, type="", code=200, status="OK", detail=""):
199 """Create a :api:`twisted.web.resource.Resource` for a JSON API errors
200 object.
202 resource.Resource.__init__(self)
203 self.id = "%s" % id
204 self.type = type
205 self.code = code
206 self.status = status
207 self.detail = detail
209 def render_GET(self, request):
210 # status codes and messages are at the JSON API layer, not HTTP layer:
211 data = {
212 'errors': [{
213 'id': self.id,
214 'type': self.type,
215 'version': MOAT_API_VERSION,
216 'code': self.code,
217 'status': self.status,
218 'detail': self.detail,
221 return self.formatDataForResponse(data, request)
223 render_POST = render_GET
226 resource403 = JsonAPIErrorResource(code=403, status="Forbidden")
227 resource404 = JsonAPIErrorResource(code=404, status="Not Found")
228 resource406 = JsonAPIErrorResource(code=406, status="Not Acceptable")
229 resource415 = JsonAPIErrorResource(code=415, status="Unsupported Media Type")
230 resource419 = JsonAPIErrorResource(code=419, status="No You're A Teapot")
231 resource501 = JsonAPIErrorResource(code=501, status="Not Implemented")
234 class CustomErrorHandlingResource(resource.Resource):
235 """A :api:`twisted.web.resource.Resource` which wraps the
236 :api:`twisted.web.resource.Resource.getChild` method in order to use
237 custom error handling pages.
239 def getChild(self, path, request):
240 logging.debug("[501] %s %s" % (request.method, request.uri))
242 response = resource501
243 response.detail = "moat version %s does not implement %s %s" % \
244 (MOAT_API_VERSION, request.method.decode('utf-8'), request.uri.decode('utf-8'))
245 return response
248 class JsonAPIDataResource(JsonAPIResource):
249 """A resource which returns some JSON API data."""
251 def __init__(self, useForwardedHeader=True, skipLoopback=False):
252 JsonAPIResource.__init__(self, useForwardedHeader, skipLoopback)
254 def checkRequestHeaders(self, request):
255 """The JSON API specification requires servers to respond with certain HTTP
256 status codes and message if the client's request headers are inappropriate in
257 any of the following ways:
259 * Servers MUST respond with a 415 Unsupported Media Type status code if
260 a request specifies the header Content-Type: application/vnd.api+json
261 with any media type parameters.
263 * Servers MUST respond with a 406 Not Acceptable status code if a
264 request’s Accept header contains the JSON API media type and all
265 instances of that media type are modified with media type parameters.
267 supports_json_api = False
268 accept_json_api_header = False
269 accept_header_is_ok = False
271 if request.requestHeaders.hasHeader("Content-Type"):
272 headers = request.requestHeaders.getRawHeaders("Content-Type")
273 # The "pragma: no cover"s are because, no matter what I do, I cannot
274 # for the life of me trick twisted's test infrastructure to not send
275 # some variant of these headers. ¯\_(ツ)_/¯
276 for contentType in headers: # pragma: no cover
277 # The request must have the Content-Type set to 'application/vnd.api+json':
278 if contentType == 'application/vnd.api+json':
279 supports_json_api = True
280 # The request must not specify a Content-Type with media parameters:
281 if ';' in contentType:
282 supports_json_api = False
284 if not supports_json_api:
285 return resource415
287 # If the request has an Accept header which contains
288 # 'application/vnd.api+json' then at least one instance of that type
289 # must have no parameters:
290 if request.requestHeaders.hasHeader("Accept"): # pragma: no cover
291 headers = request.requestHeaders.getRawHeaders("Accept")
292 for accept in headers:
293 if accept.startswith('application/vnd.api+json'):
294 accept_json_api_header = True
295 if ';' not in accept:
296 accept_header_is_ok = True
298 if accept_json_api_header and not accept_header_is_ok: # pragma: no cover
299 return resource406
302 class CaptchaResource(JsonAPIDataResource):
303 """A CAPTCHA."""
305 def __init__(self, hmacKey=None, publicKey=None, secretKey=None,
306 useForwardedHeader=True, skipLoopback=False):
307 JsonAPIDataResource.__init__(self, useForwardedHeader, skipLoopback)
308 self.hmacKey = hmacKey
309 self.publicKey = publicKey
310 self.secretKey = secretKey
313 class CaptchaFetchResource(CaptchaResource):
314 """A resource to retrieve a CAPTCHA challenge."""
316 isLeaf = True
318 def __init__(self, hmacKey=None, publicKey=None, secretKey=None,
319 captchaDir="captchas", useForwardedHeader=True, skipLoopback=False):
320 """DOCDOC
322 :param bytes hmacKey: The master HMAC key, used for validating CAPTCHA
323 challenge strings in :meth:`captcha.GimpCaptcha.check`. The file
324 where this key is stored can be set via the
325 ``GIMP_CAPTCHA_HMAC_KEYFILE`` option in the config file.
326 are stored. See the ``GIMP_CAPTCHA_DIR`` config setting.
327 :param str secretkey: A PKCS#1 OAEP-padded, private RSA key, used for
328 verifying the client's solution to the CAPTCHA. See
329 :func:`bridgedb.crypto.getRSAKey` and the
330 ``GIMP_CAPTCHA_RSA_KEYFILE`` config setting.
331 :param str publickey: A PKCS#1 OAEP-padded, public RSA key, used for
332 creating the ``captcha_challenge_field`` string to give to a
333 client.
334 :param str captchaDir: The directory where the cached CAPTCHA images
335 :param bool useForwardedHeader: If ``True``, obtain the client's IP
336 address from the ``X-Forwarded-For`` HTTP header.
337 :param bool skipLoopback: Skip loopback addresses when parsing the
338 X-Forwarded-For header.
340 CaptchaResource.__init__(self, hmacKey, publicKey, secretKey,
341 useForwardedHeader)
342 self.captchaDir = captchaDir
343 self.supportedTransports = getSupportedTransports()
345 def getCaptchaImage(self, request):
346 """Get a random CAPTCHA image from our **captchaDir**.
348 Creates a :class:`~bridgedb.captcha.GimpCaptcha`, and calls its
349 :meth:`~bridgedb.captcha.GimpCaptcha.get` method to return a random
350 CAPTCHA and challenge string.
352 :type request: :api:`twisted.web.http.Request`
353 :param request: A client's initial request for some other resource
354 which is protected by this one (i.e. protected by a CAPTCHA).
355 :returns: A 2-tuple of ``(image, challenge)``, where::
356 - ``image`` is a string holding a binary, JPEG-encoded image.
357 - ``challenge`` is a unique string associated with the request.
359 # Create a new HMAC key, specific to requests from this client:
360 clientIP = self.getClientIP(request)
361 clientHMACKey = crypto.getHMAC(self.hmacKey, clientIP)
362 capt = captcha.GimpCaptcha(self.publicKey, self.secretKey,
363 clientHMACKey, self.captchaDir)
364 try:
365 capt.get()
366 except captcha.GimpCaptchaError as error:
367 logging.debug(error)
368 except Exception as impossible:
369 logging.error("Unhandled error while retrieving Gimp captcha!")
370 logging.error(impossible)
372 return (capt.image, capt.challenge.decode('utf-8') if isinstance(capt.challenge, bytes) else capt.challenge)
374 def getPreferredTransports(self, supportedTransports):
375 """Choose which transport a client should request, based on their list
376 of ``supportedTransports``.
378 :param list supportedTransports: A list of transports the client
379 reported that they support (as returned from
380 :meth:`~bridgedb.distributors.moat.server.CaptchaFetchResource.extractSupportedTransports`).
381 :rtype: str or list
382 :returns: A string specifying the chosen transport, provided there is an
383 overlap between which transports BridgeDB and the client support.
384 Otherwise, if there is no overlap, returns a list of all the
385 transports which BridgeDB *does* support.
387 preferenceOrder = getPreferredTransports()
388 preferred = None
390 for pt in preferenceOrder:
391 if pt in supportedTransports:
392 preferred = pt
394 # If we couldn't pick the best one that we both support, return the
395 # whole list of what we're able to distribute:
396 if not preferred:
397 preferred = getSupportedTransports()
399 return preferred
401 def extractSupportedTransports(self, request):
402 """Extract the transports a client supports from their POST request.
404 :param str request: A JSON blob containing the following
405 fields:
406 * "version": The moat protocol version.
407 * "type": "client-transports".
408 * "supported": ['TRANSPORT', … ]
409 where:
410 * TRANSPORT is a string identifying a transport, e.g. "obfs4".
411 :rtype: list
412 :returns: The list of transports the client supports.
414 supported = []
416 try:
417 encoded_data = request.content.read()
418 data = json.loads(encoded_data)["data"][0]
420 if data["type"] != "client-transports":
421 raise ValueError(
422 "Bad JSON API object type: expected %s got %s" %
423 ('client-transports', data["type"]))
424 elif data["version"] != MOAT_API_VERSION:
425 raise ValueError(
426 "Client requested protocol version %s, but we're using %s" %
427 (data["version"], MOAT_API_VERSION))
428 elif not data["supported"]:
429 raise ValueError(
430 "Client didn't provide any supported transports")
431 else:
432 supported = data["supported"]
433 except KeyError as err:
434 logging.debug(("Error processing client POST request: Client JSON "
435 "API data missing '%s' field") % (err))
436 except ValueError as err:
437 logging.warn("Error processing client POST request: %s" % err)
438 except Exception as impossible:
439 logging.error("Unhandled error while extracting moat client transports!")
440 logging.error(impossible)
442 return supported
444 def render_POST(self, request):
445 """Retrieve a captcha from the moat API server and serve it to the client.
447 :type request: :api:`twisted.web.http.Request`
448 :param request: A ``Request`` object for a CAPTCHA.
449 :rtype: str
450 :returns: A JSON blob containing the following fields:
451 * "version": The moat protocol version.
452 * "image": A base64-encoded CAPTCHA JPEG image.
453 * "challenge": A base64-encoded, encrypted challenge. The client
454 will need to hold on to the and pass it back later, along with
455 their challenge response.
456 * "error": An ASCII error message.
457 Any of the above JSON fields may be "null".
459 error = self.checkRequestHeaders(request)
461 if error: # pragma: no cover
462 return error.render(request)
464 supported = self.extractSupportedTransports(request)
465 preferred = self.getPreferredTransports(supported)
466 image, challenge = self.getCaptchaImage(request)
468 data = {
469 'data': [{
470 'id': '1',
471 'type': 'moat-challenge',
472 'version': MOAT_API_VERSION,
473 'transport': preferred,
474 'image': image,
475 'challenge': challenge, # The challenge is already base64-encoded
479 try:
480 data["data"][0]["image"] = base64.b64encode(image).decode('utf-8')
481 except Exception as impossible:
482 logging.error("Could not construct or encode captcha!")
483 logging.error(impossible)
485 return self.formatDataForResponse(data, request)
488 class CaptchaCheckResource(CaptchaResource):
489 """A resource to verify a CAPTCHA solution and distribute bridges."""
491 isLeaf = True
493 def __init__(self, distributor, schedule, N=1,
494 hmacKey=None, publicKey=None, secretKey=None,
495 useForwardedHeader=True, skipLoopback=False):
496 """Create a new resource for checking CAPTCHA solutions and returning
497 bridges to a client.
499 :type distributor: :class:`MoatDistributor`
500 :param distributor: The mechanism to retrieve bridges for this
501 distributor.
502 :type schedule: :class:`~bridgedb.schedule.ScheduledInterval`
503 :param schedule: The time period used to tweak the bridge selection
504 procedure.
505 :param int N: The number of bridges to hand out per query.
506 :param bool useForwardedHeader: Whether or not we should use the the
507 X-Forwarded-For header instead of the source IP address.
508 :param bool skipLoopback: Skip loopback addresses when parsing the
509 X-Forwarded-For header.
511 CaptchaResource.__init__(self, hmacKey, publicKey, secretKey,
512 useForwardedHeader)
513 self.distributor = distributor
514 self.schedule = schedule
515 self.nBridgesToGive = N
516 self.useForwardedHeader = useForwardedHeader
518 def createBridgeRequest(self, ip, data):
519 """Create an appropriate :class:`MoatBridgeRequest` from the ``data``
520 of a client's request.
522 :param str ip: The client's IP address.
523 :param dict data: The decoded JSON API data from the client's request.
524 :rtype: :class:`MoatBridgeRequest`
525 :returns: An object which specifies the filters for retreiving
526 the type of bridges that the client requested.
528 logging.debug("Creating moat bridge request object for %s." % ip)
530 bridgeRequest = MoatBridgeRequest()
532 if ip and data:
533 bridgeRequest.client = IPAddress(ip)
534 bridgeRequest.isValid(True)
535 bridgeRequest.withIPversion()
536 bridgeRequest.withPluggableTransportType(data)
537 bridgeRequest.withoutBlockInCountry(data)
538 bridgeRequest.generateFilters()
540 return bridgeRequest
542 def getBridges(self, bridgeRequest):
543 """Get bridges for a client's HTTP request.
545 :type bridgeRequest: :class:`MoatBridgeRequest`
546 :param bridgeRequest: A valid bridge request object with pre-generated
547 filters (as returned by :meth:`createBridgeRequest`).
548 :rtype: list
549 :return: A list of :class:`~bridgedb.bridges.Bridge`s.
551 bridges = list()
552 interval = self.schedule.intervalStart(time.time())
554 logging.debug("Replying to JSON API request from %s." % bridgeRequest.client)
556 if bridgeRequest.isValid():
557 bridges = self.distributor.getBridges(bridgeRequest, interval)
559 return bridges
561 def getBridgeLines(self, bridgeRequest, bridges):
563 :type bridgeRequest: :class:`MoatBridgeRequest`
564 :param bridgeRequest: A valid bridge request object with pre-generated
565 filters (as returned by :meth:`createBridgeRequest`).
566 :param list bridges: A list of :class:`~bridgedb.bridges.Bridge`
567 objects.
568 :rtype: list
569 :return: A list of bridge lines.
571 return [replaceControlChars(bridge.getBridgeLine(bridgeRequest))
572 for bridge in bridges]
574 def extractClientSolution(self, data):
575 """Extract the client's CAPTCHA solution from a POST request.
577 This is used after receiving a POST request from a client (which
578 should contain their solution to the CAPTCHA), to extract the solution
579 and challenge strings.
581 :param dict data: The decoded JSON API data from the client's request.
582 :returns: A redirect for a request for a new CAPTCHA if there was a
583 problem. Otherwise, returns a 2-tuple of strings, the first is the
584 client's CAPTCHA solution from the text input area, and the second
585 is the challenge string.
587 qrcode = False
588 transport = None
589 challenge, solution = None, None
591 try:
592 if data["type"] != "moat-solution":
593 raise ValueError(
594 "Bad JSON API object type: expected %s got %s" %
595 ("moat-solution", data["type"]))
596 elif data["id"] != "2":
597 raise ValueError(
598 "Bad JSON API data id: expected 2 got %s" %
599 (data["id"]))
600 elif data["version"] != MOAT_API_VERSION:
601 raise ValueError(
602 "Client requested protocol version %s, but we're using %s" %
603 (data["version"], MOAT_API_VERSION))
604 elif data["transport"] not in getSupportedTransports():
605 raise ValueError(
606 "Transport '%s' is not currently supported" %
607 data["transport"])
608 else:
609 qrcode = True if data["qrcode"] == "true" else False
610 transport = type('')(data["transport"])
611 challenge = type('')(data["challenge"])
612 solution = type('')(data["solution"])
613 except KeyError as err:
614 logging.warn(("Error processing client POST request: "
615 "Client JSON API data missing '%s' field.") % err)
616 except ValueError as err:
617 logging.warn("Error processing client POST request: %s" % err)
618 except Exception as impossible:
619 logging.error(impossible)
621 return (qrcode, transport, challenge, solution)
623 def checkSolution(self, challenge, solution, clientIP):
624 """Process a solved CAPTCHA via
625 :meth:`bridgedb.captcha.GimpCaptcha.check`.
627 :param str challenge: A base64-encoded, encrypted challenge.
628 :param str solution: The client's solution to the captcha
629 :param str clientIP: The client's IP address.
630 :rtupe: bool
631 :returns: True, if the CAPTCHA solution was valid; False otherwise.
633 valid = False
634 clientHMACKey = crypto.getHMAC(self.hmacKey, clientIP)
636 try:
637 valid = captcha.GimpCaptcha.check(challenge, solution,
638 self.secretKey, clientHMACKey)
639 except Exception as impossible:
640 logging.error(impossible)
641 raise impossible
642 finally:
643 logging.debug("%sorrect captcha from %r: %r." %
644 ("C" if valid else "Inc", clientIP, solution))
646 return valid
648 def failureResponse(self, id, request, bridgeRequest=None):
649 """Respond with status code "419 No You're A Teapot" if the captcha
650 verification failed, or status code "404 Not Found" if there
651 were none of the type of bridges requested.
653 :param int id: The JSON API "id" field of the
654 ``JsonAPIErrorResource`` which should be returned.
655 :type request: :api:`twisted.web.http.Request`
656 :param request: The current request we're handling.
657 :type bridgeRequest: :class:`MoatBridgeRequest`
658 :param bridgeRequest: A valid bridge request object with pre-generated
659 filters (as returned by :meth:`createBridgeRequest`).
661 if id == 4:
662 error_response = resource419
663 error_response.id = "4"
664 error_response.detail = "The CAPTCHA solution was incorrect."
665 elif id == 5:
666 error_response = resource419
667 error_response.id = "5"
668 error_response.detail = "The CAPTCHA challenge timed out."
669 elif id == 6:
670 error_response = resource404
671 error_response.id = "6"
672 error_response.detail = ("No bridges available to fulfill "
673 "request: %s.") % bridgeRequest
675 error_response.type = 'moat-bridges'
677 return error_response.render(request)
679 def render_POST(self, request):
680 """Process a client's CAPTCHA solution.
682 If the client's CAPTCHA solution is valid (according to
683 :meth:`checkSolution`), process and serve their original
684 request. Otherwise, redirect them back to a new CAPTCHA page.
686 :type request: :api:`twisted.web.http.Request`
687 :param request: A ``Request`` object, including POST arguments which
688 should include two key/value pairs: one key being
689 ``'captcha_challenge_field'``, and the other,
690 ``'captcha_response_field'``. These POST arguments should be
691 obtained from :meth:`render_GET`.
692 :rtype: str
693 :returns: A rendered HTML page containing a ReCaptcha challenge image
694 for the client to solve.
696 valid = False
697 error = self.checkRequestHeaders(request)
699 if error: # pragma: no cover
700 logging.debug("Error while checking moat request headers.")
701 moatMetrix.recordInvalidMoatRequest(request)
702 return error.render(request)
704 data = {
705 "data": [{
706 "id": '3',
707 "type": 'moat-bridges',
708 "version": MOAT_API_VERSION,
709 "bridges": None,
710 "qrcode": None,
714 try:
715 pos = request.content.tell()
716 encoded_client_data = request.content.read()
717 # We rewind the stream to its previous position to allow the
718 # metrix module to read the request's content too.
719 request.content.seek(pos)
720 client_data = json.loads(encoded_client_data)["data"][0]
721 clientIP = self.getClientIP(request)
723 (include_qrcode, transport,
724 challenge, solution) = self.extractClientSolution(client_data)
726 valid = self.checkSolution(challenge, solution, clientIP)
727 except captcha.CaptchaExpired:
728 logging.debug("The challenge had timed out")
729 moatMetrix.recordInvalidMoatRequest(request)
730 return self.failureResponse(5, request)
731 except Exception as impossible:
732 logging.warn("Unhandled exception while processing a POST /fetch request!")
733 logging.error(impossible)
734 moatMetrix.recordInvalidMoatRequest(request)
735 return self.failureResponse(4, request)
737 if valid:
738 qrcode = None
739 bridgeRequest = self.createBridgeRequest(clientIP, client_data)
740 bridges = self.getBridges(bridgeRequest)
741 bridgeLines = self.getBridgeLines(bridgeRequest, bridges)
742 moatMetrix.recordValidMoatRequest(request)
744 # If we can only return less than the configured
745 # MOAT_BRIDGES_PER_ANSWER then log a warning.
746 if len(bridgeLines) < self.nBridgesToGive:
747 logging.warn(("Not enough bridges of the type specified to "
748 "fulfill the following request: %s") % bridgeRequest)
749 if not bridgeLines:
750 internalMetrix.recordEmptyMoatResponse()
751 else:
752 internalMetrix.recordHandoutsPerBridge(bridgeRequest, bridges)
754 if antibot.isRequestFromBot(request):
755 ttype = transport or "vanilla"
756 bridgeLines = antibot.getDecoyBridge(ttype,
757 bridgeRequest.ipVersion)
759 # If we have no bridges at all to give to the client, then
760 # return a JSON API 404 error.
761 if not bridgeLines:
762 return self.failureResponse(6, request)
764 if include_qrcode:
765 qrjpeg = generateQR(bridgeLines)
766 if qrjpeg:
767 qrcode = 'data:image/jpeg;base64,%s' % base64.b64encode(qrjpeg)
769 data["data"][0]["qrcode"] = qrcode
770 data["data"][0]["bridges"] = bridgeLines
772 return self.formatDataForResponse(data, request)
773 else:
774 moatMetrix.recordInvalidMoatRequest(request)
775 return self.failureResponse(4, request)
778 def addMoatServer(config, distributor):
779 """Set up a web server for moat bridge distribution.
781 :type config: :class:`bridgedb.persistent.Conf`
782 :param config: A configuration object from
783 :mod:`bridgedb.main`. Currently, we use these options::
784 GIMP_CAPTCHA_DIR
785 SERVER_PUBLIC_FQDN
786 SUPPORTED_TRANSPORTS
787 MOAT_DIST
788 MOAT_DIST_VIA_MEEK_ONLY
789 MOAT_TLS_CERT_FILE
790 MOAT_TLS_KEY_FILE
791 MOAT_SERVER_PUBLIC_ROOT
792 MOAT_HTTPS_IP
793 MOAT_HTTPS_PORT
794 MOAT_HTTP_IP
795 MOAT_HTTP_PORT
796 MOAT_BRIDGES_PER_ANSWER
797 MOAT_TRANSPORT_PREFERENCE_LIST
798 MOAT_USE_IP_FROM_FORWARDED_HEADER
799 MOAT_SKIP_LOOPBACK_ADDRESSES
800 MOAT_ROTATION_PERIOD
801 MOAT_GIMP_CAPTCHA_HMAC_KEYFILE
802 MOAT_GIMP_CAPTCHA_RSA_KEYFILE
803 :type distributor: :class:`bridgedb.distributors.moat.distributor.MoatDistributor`
804 :param distributor: A bridge distributor.
805 :raises SystemExit: if the servers cannot be started.
806 :rtype: :api:`twisted.web.server.Site`
807 :returns: A webserver.
809 captcha = None
810 fwdHeaders = config.MOAT_USE_IP_FROM_FORWARDED_HEADER
811 numBridges = config.MOAT_BRIDGES_PER_ANSWER
812 skipLoopback = config.MOAT_SKIP_LOOPBACK_ADDRESSES
814 logging.info("Starting moat servers...")
816 setFQDN(config.SERVER_PUBLIC_FQDN)
817 setRoot(config.MOAT_SERVER_PUBLIC_ROOT)
818 setSupportedTransports(config.SUPPORTED_TRANSPORTS)
819 setPreferredTransports(config.MOAT_TRANSPORT_PREFERENCE_LIST)
821 # Get the master HMAC secret key for CAPTCHA challenges, and then
822 # create a new HMAC key from it for use on the server.
823 captchaKey = crypto.getKey(config.MOAT_GIMP_CAPTCHA_HMAC_KEYFILE)
824 hmacKey = crypto.getHMAC(captchaKey, "Moat-Captcha-Key")
825 # Load or create our encryption keys:
826 secretKey, publicKey = crypto.getRSAKey(config.MOAT_GIMP_CAPTCHA_RSA_KEYFILE)
827 sched = Unscheduled()
829 if config.MOAT_ROTATION_PERIOD:
830 count, period = config.MOAT_ROTATION_PERIOD.split()
831 sched = ScheduledInterval(count, period)
833 sitePublicDir = getRoot()
835 meek = CustomErrorHandlingResource()
836 moat = CustomErrorHandlingResource()
837 fetch = CaptchaFetchResource(hmacKey, publicKey, secretKey,
838 config.GIMP_CAPTCHA_DIR,
839 fwdHeaders, skipLoopback)
840 check = CaptchaCheckResource(distributor, sched, numBridges,
841 hmacKey, publicKey, secretKey,
842 fwdHeaders, skipLoopback)
844 moat.putChild(b"fetch", fetch)
845 moat.putChild(b"check", check)
846 meek.putChild(b"moat", moat)
848 root = CustomErrorHandlingResource()
849 root.putChild(b"meek", meek)
850 root.putChild(b"moat", moat)
852 site = Site(root)
853 site.displayTracebacks = False
855 if config.MOAT_HTTP_PORT: # pragma: no cover
856 ip = config.MOAT_HTTP_IP or ""
857 port = config.MOAT_HTTP_PORT or 80
858 try:
859 reactor.listenTCP(port, site, interface=ip)
860 except CannotListenError as error:
861 raise SystemExit(error)
862 logging.info("Started Moat HTTP server on %s:%d" % (str(ip), int(port)))
864 if config.MOAT_HTTPS_PORT: # pragma: no cover
865 ip = config.MOAT_HTTPS_IP or ""
866 port = config.MOAT_HTTPS_PORT or 443
867 try:
868 from twisted.internet.ssl import DefaultOpenSSLContextFactory
869 factory = DefaultOpenSSLContextFactory(config.MOAT_TLS_KEY_FILE,
870 config.MOAT_TLS_CERT_FILE)
871 reactor.listenSSL(port, site, factory, interface=ip)
872 except CannotListenError as error:
873 raise SystemExit(error)
874 logging.info("Started Moat TLS server on %s:%d" % (str(ip), int(port)))
876 return site