#!/usr/bin/env python
# Copyright 2012 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Retrieve web resources over http."""
import copy
import datetime
import httplib
import logging
import random
import ssl
import StringIO
import httparchive
import platformsettings
import script_injector
# PIL isn't always available, but we still want to be able to run without
# the image scrambling functionality in this case.
try:
import Image
except ImportError:
Image = None
TIMER = platformsettings.timer
class HttpClientException(Exception):
"""Base class for all exceptions in httpclient."""
pass
def _InjectScripts(response, injector):
"""Injects script generated by |injector| immediately after <head> or <html>.
Copies |response| if it is modified.
Args:
response: an ArchivedHttpResponse
injector: function which generates JavaScript string
based on recording time (e.g. "Math.random = function(){...}")
Returns:
an ArchivedHttpResponse
"""
if type(response) == tuple:
logging.warn('tuple response: %s', response)
content_type = response.get_header('content-type')
if content_type and content_type.startswith('text/html'):
text_chunks = response.get_data_as_chunks()
text_chunks, just_injected = script_injector.InjectScript(
text_chunks, 'text/html', injector(response.request_time))
if just_injected:
response = copy.deepcopy(response)
response.set_data_from_chunks(text_chunks)
return response
def _ScrambleImages(response):
"""If the |response| is an image, attempt to scramble it.
Copies |response| if it is modified.
Args:
response: an ArchivedHttpResponse
Returns:
an ArchivedHttpResponse
"""
assert Image, '--scramble_images requires the PIL module to be installed.'
content_type = response.get_header('content-type')
if content_type and content_type.startswith('image/'):
try:
image_data = response.response_data[0]
image_data.decode(encoding='base64')
im = Image.open(StringIO.StringIO(image_data))
pixel_data = list(im.getdata())
random.shuffle(pixel_data)
scrambled_image = im.copy()
scrambled_image.putdata(pixel_data)
output_image_io = StringIO.StringIO()
scrambled_image.save(output_image_io, im.format)
output_image_data = output_image_io.getvalue()
output_image_data.encode(encoding='base64')
response = copy.deepcopy(response)
response.set_data(output_image_data)
except Exception:
pass
return response
class DetailedHTTPResponse(httplib.HTTPResponse):
"""Preserve details relevant to replaying responses.
WARNING: This code uses attributes and methods of HTTPResponse
that are not part of the public interface.
"""
def read_chunks(self):
"""Return the response body content and timing data.
The returned chunks have the chunk size and CRLFs stripped off.
If the response was compressed, the returned data is still compressed.
Returns:
(chunks, delays)
chunks:
[response_body] # non-chunked responses
[chunk_1, chunk_2, ...] # chunked responses
delays:
[0] # non-chunked responses
[chunk_1_first_byte_delay, ...] # chunked responses
The delay for the first body item should be recorded by the caller.
"""
buf = []
chunks = []
delays = []
if not self.chunked:
chunks.append(self.read())
delays.append(0)
else:
start = TIMER()
try:
while True:
line = self.fp.readline()
chunk_size = self._read_chunk_size(line)
if chunk_size is None:
raise httplib.IncompleteRead(''.join(chunks))
if chunk_size == 0:
break
delays.append(TIMER() - start)
chunks.append(self._safe_read(chunk_size))
self._safe_read(2) # skip the CRLF at the end of the chunk
start = TIMER()
# Ignore any trailers.
while True:
line = self.fp.readline()
if not line or line == '\r\n':
break
finally:
self.close()
return chunks, delays
@classmethod
def _read_chunk_size(cls, line):
chunk_extensions_pos = line.find(';')
if chunk_extensions_pos != -1:
line = line[:chunk_extensions_pos] # strip chunk-extensions
try:
chunk_size = int(line, 16)
except ValueError:
return None
return chunk_size
class DetailedHTTPConnection(httplib.HTTPConnection):
"""Preserve details relevant to replaying connections."""
response_class = DetailedHTTPResponse
class DetailedHTTPSResponse(DetailedHTTPResponse):
"""Preserve details relevant to replaying SSL responses."""
pass
class DetailedHTTPSConnection(httplib.HTTPSConnection):
"""Preserve details relevant to replaying SSL connections."""
response_class = DetailedHTTPSResponse
def __init__(self, host, port):
# https://www.python.org/dev/peps/pep-0476/#opting-out
if hasattr(ssl, '_create_unverified_context'):
httplib.HTTPSConnection.__init__(
self, host=host, port=port, context=ssl._create_unverified_context())
else:
httplib.HTTPSConnection.__init__(self, host=host, port=port)
class RealHttpFetch(object):
def __init__(self, real_dns_lookup):
"""Initialize RealHttpFetch.
Args:
real_dns_lookup: a function that resolves a host to an IP. RealHttpFetch
will resolve host name to the IP before making fetching request if this
is not None.
"""
self._real_dns_lookup = real_dns_lookup
@staticmethod
def _GetHeaderNameValue(header):
"""Parse the header line and return a name/value tuple.
Args:
header: a string for a header such as "Content-Length: 314".
Returns:
A tuple (header_name, header_value) on success or None if the header
is not in expected format. header_name is in lowercase.
"""
i = header.find(':')
if i > 0:
return (header[:i].lower(), header[i+1:].strip())
return None
@staticmethod
def _ToTuples(headers):
"""Parse headers and save them to a list of tuples.
This method takes HttpResponse.msg.headers as input and convert it
to a list of (header_name, header_value) tuples.
HttpResponse.msg.headers is a list of strings where each string
represents either a header or a continuation line of a header.
1. a normal header consists of two parts which are separated by colon :
"header_name:header_value..."
2. a continuation line is a string starting with whitespace
"[whitespace]continued_header_value..."
If a header is not in good shape or an unexpected continuation line is
seen, it will be ignored.
Should avoid using response.getheaders() directly
because response.getheaders() can't handle multiple headers
with the same name properly. Instead, parse the
response.msg.headers using this method to get all headers.
Args:
headers: an instance of HttpResponse.msg.headers.
Returns:
A list of tuples which looks like:
[(header_name, header_value), (header_name2, header_value2)...]
"""
all_headers = []
for line in headers:
if line[0] in '\t ':
if not all_headers:
logging.warning(
'Unexpected response header continuation line [%s]', line)
continue
name, value = all_headers.pop()
value += '\n ' + line.strip()
else:
name_value = RealHttpFetch._GetHeaderNameValue(line)
if not name_value:
logging.warning(
'Response header in wrong format [%s]', line)
continue
name, value = name_value # pylint: disable=unpacking-non-sequence
all_headers.append((name, value))
return all_headers
@staticmethod
def _get_request_host_port(request):
host_parts = request.host.split(':')
host = host_parts[0]
port = int(host_parts[1]) if len(host_parts) == 2 else None
return host, port
@staticmethod
def _get_system_proxy(is_ssl):
return platformsettings.get_system_proxy(is_ssl)
def _get_connection(self, request_host, request_port, is_ssl):
"""Return a detailed connection object for host/port pair.
If a system proxy is defined (see platformsettings.py), it will be used.
Args:
request_host: a host string (e.g. "www.example.com").
request_port: a port integer (e.g. 8080) or None (for the default port).
is_ssl: True if HTTPS connection is needed.
Returns:
A DetailedHTTPSConnection or DetailedHTTPConnection instance.
"""
connection_host = request_host
connection_port = request_port
system_proxy = self._get_system_proxy(is_ssl)
if system_proxy:
connection_host = system_proxy.host
connection_port = system_proxy.port
# Use an IP address because WPR may override DNS settings.
if self._real_dns_lookup:
connection_ip = self._real_dns_lookup(connection_host)
if not connection_ip:
logging.critical(
'Unable to find IP for host name: %s', connection_host)
return None
connection_host = connection_ip
if is_ssl:
connection = DetailedHTTPSConnection(connection_host, connection_port)
if system_proxy:
connection.set_tunnel(request_host, request_port)
else:
connection = DetailedHTTPConnection(connection_host, connection_port)
return connection
def __call__(self, request):
"""Fetch an HTTP request.
Args:
request: an ArchivedHttpRequest
Returns:
an ArchivedHttpResponse
"""
logging.debug('RealHttpFetch: %s %s', request.host, request.full_path)
request_host, request_port = self._get_request_host_port(request)
retries = 3
while True:
try:
request_time = datetime.datetime.utcnow()
connection = self._get_connection(
request_host, request_port, request.is_ssl)
connect_start = TIMER()
connection.connect()
connect_delay = int((TIMER() - connect_start) * 1000)
start = TIMER()
connection.request(
request.command,
request.full_path,
request.request_body,
request.headers)
response = connection.getresponse()
headers_delay = int((TIMER() - start) * 1000)
chunks, chunk_delays = response.read_chunks()
delays = {
'connect': connect_delay,
'headers': headers_delay,
'data': chunk_delays
}
archived_http_response = httparchive.ArchivedHttpResponse(
response.version,
response.status,
response.reason,
RealHttpFetch._ToTuples(response.msg.headers),
chunks,
delays,
request_time)
return archived_http_response
except Exception, e:
if retries:
retries -= 1
logging.warning('Retrying fetch %s: %s', request, repr(e))
continue
logging.critical('Could not fetch %s: %s', request, repr(e))
return None
class RecordHttpArchiveFetch(object):
"""Make real HTTP fetches and save responses in the given HttpArchive."""
def __init__(self, http_archive, injector):
"""Initialize RecordHttpArchiveFetch.
Args:
http_archive: an instance of a HttpArchive
injector: script injector to inject scripts in all pages
"""
self.http_archive = http_archive
# Do not resolve host name to IP when recording to avoid SSL3 handshake
# failure.
# See https://github.com/chromium/web-page-replay/issues/73 for details.
self.real_http_fetch = RealHttpFetch(real_dns_lookup=None)
self.injector = injector
def __call__(self, request):
"""Fetch the request and return the response.
Args:
request: an ArchivedHttpRequest.
Returns:
an ArchivedHttpResponse
"""
# If request is already in the archive, return the archived response.
if request in self.http_archive:
logging.debug('Repeated request found: %s', request)
response = self.http_archive[request]
else:
response = self.real_http_fetch(request)
if response is None:
return None
self.http_archive[request] = response
if self.injector:
response = _InjectScripts(response, self.injector)
logging.debug('Recorded: %s', request)
return response
class ReplayHttpArchiveFetch(object):
"""Serve responses from the given HttpArchive."""
def __init__(self, http_archive, real_dns_lookup, injector,
use_diff_on_unknown_requests=False,
use_closest_match=False, scramble_images=False):
"""Initialize ReplayHttpArchiveFetch.
Args:
http_archive: an instance of a HttpArchive
real_dns_lookup: a function that resolves a host to an IP.
injector: script injector to inject scripts in all pages
use_diff_on_unknown_requests: If True, log unknown requests
with a diff to requests that look similar.
use_closest_match: If True, on replay mode, serve the closest match
in the archive instead of giving a 404.
"""
self.http_archive = http_archive
self.injector = injector
self.use_diff_on_unknown_requests = use_diff_on_unknown_requests
self.use_closest_match = use_closest_match
self.scramble_images = scramble_images
self.real_http_fetch = RealHttpFetch(real_dns_lookup)
def __call__(self, request):
"""Fetch the request and return the response.
Args:
request: an instance of an ArchivedHttpRequest.
Returns:
Instance of ArchivedHttpResponse (if found) or None
"""
if request.host.startswith('127.0.0.1:'):
return self.real_http_fetch(request)
response = self.http_archive.get(request)
if self.use_closest_match and not response:
closest_request = self.http_archive.find_closest_request(
request, use_path=True)
if closest_request:
response = self.http_archive.get(closest_request)
if response:
logging.info('Request not found: %s\nUsing closest match: %s',
request, closest_request)
if not response:
reason = str(request)
if self.use_diff_on_unknown_requests:
diff = self.http_archive.diff(request)
if diff:
reason += (
"\nNearest request diff "
"('-' for archived request, '+' for current request):\n%s" % diff)
logging.warning('Could not replay: %s', reason)
else:
if self.injector:
response = _InjectScripts(response, self.injector)
if self.scramble_images:
response = _ScrambleImages(response)
return response
class ControllableHttpArchiveFetch(object):
"""Controllable fetch function that can swap between record and replay."""
def __init__(self, http_archive, real_dns_lookup,
injector, use_diff_on_unknown_requests,
use_record_mode, use_closest_match, scramble_images):
"""Initialize HttpArchiveFetch.
Args:
http_archive: an instance of a HttpArchive
real_dns_lookup: a function that resolves a host to an IP.
injector: function to inject scripts in all pages.
takes recording time as datetime.datetime object.
use_diff_on_unknown_requests: If True, log unknown requests
with a diff to requests that look similar.
use_record_mode: If True, start in server in record mode.
use_closest_match: If True, on replay mode, serve the closest match
in the archive instead of giving a 404.
"""
self.http_archive = http_archive
self.record_fetch = RecordHttpArchiveFetch(http_archive, injector)
self.replay_fetch = ReplayHttpArchiveFetch(
http_archive, real_dns_lookup, injector,
use_diff_on_unknown_requests, use_closest_match, scramble_images)
if use_record_mode:
self.SetRecordMode()
else:
self.SetReplayMode()
def SetRecordMode(self):
self.fetch = self.record_fetch
self.is_record_mode = True
def SetReplayMode(self):
self.fetch = self.replay_fetch
self.is_record_mode = False
def __call__(self, *args, **kwargs):
"""Forward calls to Replay/Record fetch functions depending on mode."""
return self.fetch(*args, **kwargs)