#!/usr/bin/env python | |
# Copyright 2017 The Chromium Embedded Framework Authors. All rights reserved. | |
# Use of this source code is governed by a BSD-style license that can be found | |
# in the LICENSE file. | |
""" | |
This script implements a simple HTTP server for receiving crash report uploads | |
from a Breakpad/Crashpad client (any CEF-based application). This script is | |
intended for testing purposes only. An HTTPS server and a system such as Socorro | |
(https://wiki.mozilla.org/Socorro) should be used when uploading crash reports | |
from production applications. | |
Usage of this script is as follows: | |
1. Run this script from the command-line. The first argument is the server port | |
number and the second argument is the directory where uploaded report | |
information will be saved: | |
> python crash_server.py 8080 /path/to/dumps | |
2. Create a "crash_reporter.cfg" file at the required platform-specific | |
location. On Windows and Linux this file must be placed next to the main | |
application executable. On macOS this file must be placed in the top-level | |
app bundle Resources directory (e.g. "<appname>.app/Contents/Resources"). At | |
a minimum it must contain a "ServerURL=http://localhost:8080" line under the | |
"[Config]" section (make sure the port number matches the value specified in | |
step 1). See comments in include/cef_crash_util.h for a complete | |
specification of this file. | |
Example file contents: | |
[Config] | |
ServerURL=http://localhost:8080 | |
# Disable rate limiting so that all crashes are uploaded. | |
RateLimitEnabled=false | |
MaxUploadsPerDay=0 | |
[CrashKeys] | |
# The cefclient sample application sets these values (see step 5 below). | |
testkey_small1=small | |
testkey_small2=small | |
testkey_medium1=medium | |
testkey_medium2=medium | |
testkey_large1=large | |
testkey_large2=large | |
3. Load one of the following URLs in the CEF-based application to cause a crash: | |
Main (browser) process crash: chrome://inducebrowsercrashforrealz | |
Renderer process crash: chrome://crash | |
GPU process crash: chrome://gpucrash | |
4. When this script successfully receives a crash report upload you will see | |
console output like the following: | |
01/10/2017 12:31:23: Dump <id> | |
The "<id>" value is a 16 digit hexadecimal string that uniquely identifies | |
the dump. Crash dumps and metadata (product state, command-line flags, crash | |
keys, etc.) will be written to the "<id>.dmp" and "<id>.json" files | |
underneath the directory specified in step 1. | |
On Linux Breakpad uses the wget utility to upload crash dumps, so make sure | |
that utility is installed. If the crash is handled correctly then you should | |
see console output like the following when the client uploads a crash dump: | |
--2017-01-10 12:31:22-- http://localhost:8080/ | |
Resolving localhost (localhost)... 127.0.0.1 | |
Connecting to localhost (localhost)|127.0.0.1|:8080... connected. | |
HTTP request sent, awaiting response... 200 OK | |
Length: unspecified [text/html] | |
Saving to: '/dev/fd/3' | |
Crash dump id: <id> | |
On macOS when uploading a crash report to this script over HTTP you may | |
receive an error like the following: | |
"Transport security has blocked a cleartext HTTP (http://) resource load | |
since it is insecure. Temporary exceptions can be configured via your app's | |
Info.plist file." | |
You can work around this error by adding the following key to the Helper app | |
Info.plist file (e.g. "<appname>.app/Contents/Frameworks/ | |
<appname> Helper.app/Contents/Info.plist"): | |
<key>NSAppTransportSecurity</key> | |
<dict> | |
<!--Allow all connections (for testing only!)--> | |
<key>NSAllowsArbitraryLoads</key> | |
<true/> | |
</dict> | |
5. The cefclient sample application sets test crash key values in the browser | |
and renderer processes. To work properly these values must also be defined | |
in the "[CrashKeys]" section of "crash_reporter.cfg" as shown above. | |
In tests/cefclient/browser/client_browser.cc (browser process): | |
CefSetCrashKeyValue("testkey1", "value1_browser"); | |
CefSetCrashKeyValue("testkey2", "value2_browser"); | |
CefSetCrashKeyValue("testkey3", "value3_browser"); | |
In tests/cefclient/renderer/client_renderer.cc (renderer process): | |
CefSetCrashKeyValue("testkey1", "value1_renderer"); | |
CefSetCrashKeyValue("testkey2", "value2_renderer"); | |
CefSetCrashKeyValue("testkey3", "value3_renderer"); | |
When crashing the browser or renderer processes with cefclient you should | |
verify that the test crash key values are included in the metadata | |
("<id>.json") file. Some values may be chunked as described in | |
include/cef_crash_util.h. | |
""" | |
from __future__ import absolute_import | |
from __future__ import print_function | |
import cgi | |
import datetime | |
import json | |
import os | |
import shutil | |
import sys | |
import uuid | |
import zlib | |
is_python2 = sys.version_info.major == 2 | |
if is_python2: | |
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer | |
from cStringIO import StringIO as BytesIO | |
else: | |
from http.server import BaseHTTPRequestHandler, HTTPServer | |
from io import BytesIO, open | |
def print_msg(msg): | |
""" Write |msg| to stdout and flush. """ | |
timestr = datetime.datetime.now().strftime("%m/%d/%Y %H:%M:%S") | |
sys.stdout.write("%s: %s\n" % (timestr, msg)) | |
sys.stdout.flush() | |
# Key identifying the minidump file. | |
minidump_key = 'upload_file_minidump' | |
class CrashHTTPRequestHandler(BaseHTTPRequestHandler): | |
def __init__(self, dump_directory, *args): | |
self._dump_directory = dump_directory | |
BaseHTTPRequestHandler.__init__(self, *args) | |
def _send_default_response_headers(self): | |
""" Send default response headers. """ | |
self.send_response(200) | |
self.send_header('Content-type', 'text/html') | |
self.end_headers() | |
def _parse_post_data(self, data): | |
""" Returns a cgi.FieldStorage object for this request or None if this is | |
not a POST request. """ | |
if self.command != 'POST': | |
return None | |
return cgi.FieldStorage( | |
fp=BytesIO(data), | |
headers=self.headers, | |
environ={ | |
'REQUEST_METHOD': 'POST', | |
'CONTENT_TYPE': self.headers['Content-Type'], | |
}) | |
def _get_chunk_size(self): | |
# Read to the next "\r\n". | |
size_str = self.rfile.read(2) | |
while size_str[-2:] != b"\r\n": | |
size_str += self.rfile.read(1) | |
# Remove the trailing "\r\n". | |
size_str = size_str[:-2] | |
assert len(size_str) <= 4 | |
return int(size_str, 16) | |
def _get_chunk_data(self, chunk_size): | |
data = self.rfile.read(chunk_size) | |
assert len(data) == chunk_size | |
# Skip the trailing "\r\n". | |
self.rfile.read(2) | |
return data | |
def _unchunk_request(self, compressed): | |
""" Read a chunked request body. Optionally decompress the result. """ | |
if compressed: | |
d = zlib.decompressobj(16 + zlib.MAX_WBITS) | |
# Chunked format is: <size>\r\n<bytes>\r\n<size>\r\n<bytes>\r\n0\r\n | |
unchunked = b"" | |
while True: | |
chunk_size = self._get_chunk_size() | |
print('Chunk size 0x%x' % chunk_size) | |
if (chunk_size == 0): | |
break | |
chunk_data = self._get_chunk_data(chunk_size) | |
if compressed: | |
unchunked += d.decompress(chunk_data) | |
else: | |
unchunked += chunk_data | |
if compressed: | |
unchunked += d.flush() | |
return unchunked | |
def _create_new_dump_id(self): | |
""" Breakpad requires a 16 digit hexadecimal dump ID. """ | |
return uuid.uuid4().hex.upper()[0:16] | |
def do_GET(self): | |
""" Default empty implementation for handling GET requests. """ | |
self._send_default_response_headers() | |
self.wfile.write("<html><body><h1>GET!</h1></body></html>") | |
def do_HEAD(self): | |
""" Default empty implementation for handling HEAD requests. """ | |
self._send_default_response_headers() | |
def do_POST(self): | |
""" Handle a multi-part POST request submitted by Breakpad/Crashpad. """ | |
self._send_default_response_headers() | |
# Create a unique ID for the dump. | |
dump_id = self._create_new_dump_id() | |
# Return the unique ID to the caller. | |
self.wfile.write(dump_id.encode('utf-8')) | |
dmp_stream = None | |
metadata = {} | |
# Request body may be chunked and/or gzip compressed. For example: | |
# | |
# 3029 branch on Windows: | |
# User-Agent: Crashpad/0.8.0 | |
# Host: localhost:8080 | |
# Connection: Keep-Alive | |
# Transfer-Encoding: chunked | |
# Content-Type: multipart/form-data; boundary=---MultipartBoundary-vp5j9HdSRYK8DvX2DhtpqEbMNjSN1wnL--- | |
# Content-Encoding: gzip | |
# | |
# 2987 branch on Windows: | |
# User-Agent: Crashpad/0.8.0 | |
# Host: localhost:8080 | |
# Connection: Keep-Alive | |
# Content-Type: multipart/form-data; boundary=---MultipartBoundary-qFhorGA40vDJ1fgmc2mjorL0fRfKOqup--- | |
# Content-Length: 609894 | |
# | |
# 2883 branch on Linux: | |
# User-Agent: Wget/1.15 (linux-gnu) | |
# Host: localhost:8080 | |
# Accept: */* | |
# Connection: Keep-Alive | |
# Content-Type: multipart/form-data; boundary=--------------------------83572861f14cc736 | |
# Content-Length: 32237 | |
# Content-Encoding: gzip | |
print(self.headers) | |
chunked = 'Transfer-Encoding' in self.headers and self.headers['Transfer-Encoding'].lower( | |
) == 'chunked' | |
compressed = 'Content-Encoding' in self.headers and self.headers['Content-Encoding'].lower( | |
) == 'gzip' | |
if chunked: | |
request_body = self._unchunk_request(compressed) | |
else: | |
content_length = int(self.headers[ | |
'Content-Length']) if 'Content-Length' in self.headers else 0 | |
if content_length > 0: | |
request_body = self.rfile.read(content_length) | |
else: | |
request_body = self.rfile.read() | |
if compressed: | |
request_body = zlib.decompress(request_body, 16 + zlib.MAX_WBITS) | |
# Parse the multi-part request. | |
form_data = self._parse_post_data(request_body) | |
for key in form_data.keys(): | |
if key == minidump_key and form_data[minidump_key].file: | |
dmp_stream = form_data[minidump_key].file | |
else: | |
metadata[key] = form_data[key].value | |
if dmp_stream is None: | |
# Exit early if the request is invalid. | |
print_msg('Invalid dump %s' % dump_id) | |
return | |
print_msg('Dump %s' % dump_id) | |
# Write the minidump to file. | |
dump_file = os.path.join(self._dump_directory, dump_id + '.dmp') | |
with open(dump_file, 'wb') as fp: | |
shutil.copyfileobj(dmp_stream, fp) | |
# Write the metadata to file. | |
meta_file = os.path.join(self._dump_directory, dump_id + '.json') | |
if is_python2: | |
with open(meta_file, 'w') as fp: | |
json.dump( | |
metadata, | |
fp, | |
ensure_ascii=False, | |
encoding='utf-8', | |
indent=2, | |
sort_keys=True) | |
else: | |
with open(meta_file, 'w', encoding='utf-8') as fp: | |
json.dump(metadata, fp, indent=2, sort_keys=True) | |
def HandleRequestsUsing(dump_store): | |
return lambda *args: CrashHTTPRequestHandler(dump_directory, *args) | |
def RunCrashServer(port, dump_directory): | |
""" Run the crash handler HTTP server. """ | |
httpd = HTTPServer(('', port), HandleRequestsUsing(dump_directory)) | |
print_msg('Starting httpd on port %d' % port) | |
httpd.serve_forever() | |
# Program entry point. | |
if __name__ == "__main__": | |
if len(sys.argv) != 3: | |
print('Usage: %s <port> <dump_directory>' % os.path.basename(sys.argv[0])) | |
sys.exit(1) | |
# Create the dump directory if necessary. | |
dump_directory = sys.argv[2] | |
if not os.path.exists(dump_directory): | |
os.makedirs(dump_directory) | |
if not os.path.isdir(dump_directory): | |
raise Exception('Directory does not exist: %s' % dump_directory) | |
RunCrashServer(int(sys.argv[1]), dump_directory) |