1#!/usr/bin/env python 2# Copyright 2017 The Chromium Embedded Framework Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be found 4# in the LICENSE file. 5""" 6This script implements a simple HTTP server for receiving crash report uploads 7from a Breakpad/Crashpad client (any CEF-based application). This script is 8intended for testing purposes only. An HTTPS server and a system such as Socorro 9(https://wiki.mozilla.org/Socorro) should be used when uploading crash reports 10from production applications. 11 12Usage of this script is as follows: 13 141. Run this script from the command-line. The first argument is the server port 15 number and the second argument is the directory where uploaded report 16 information will be saved: 17 18 > python crash_server.py 8080 /path/to/dumps 19 202. Create a "crash_reporter.cfg" file at the required platform-specific 21 location. On Windows and Linux this file must be placed next to the main 22 application executable. On macOS this file must be placed in the top-level 23 app bundle Resources directory (e.g. "<appname>.app/Contents/Resources"). At 24 a minimum it must contain a "ServerURL=http://localhost:8080" line under the 25 "[Config]" section (make sure the port number matches the value specified in 26 step 1). See comments in include/cef_crash_util.h for a complete 27 specification of this file. 28 29 Example file contents: 30 31 [Config] 32 ServerURL=http://localhost:8080 33 # Disable rate limiting so that all crashes are uploaded. 34 RateLimitEnabled=false 35 MaxUploadsPerDay=0 36 37 [CrashKeys] 38 # The cefclient sample application sets these values (see step 5 below). 39 testkey_small1=small 40 testkey_small2=small 41 testkey_medium1=medium 42 testkey_medium2=medium 43 testkey_large1=large 44 testkey_large2=large 45 463. Load one of the following URLs in the CEF-based application to cause a crash: 47 48 Main (browser) process crash: chrome://inducebrowsercrashforrealz 49 Renderer process crash: chrome://crash 50 GPU process crash: chrome://gpucrash 51 524. When this script successfully receives a crash report upload you will see 53 console output like the following: 54 55 01/10/2017 12:31:23: Dump <id> 56 57 The "<id>" value is a 16 digit hexadecimal string that uniquely identifies 58 the dump. Crash dumps and metadata (product state, command-line flags, crash 59 keys, etc.) will be written to the "<id>.dmp" and "<id>.json" files 60 underneath the directory specified in step 1. 61 62 On Linux Breakpad uses the wget utility to upload crash dumps, so make sure 63 that utility is installed. If the crash is handled correctly then you should 64 see console output like the following when the client uploads a crash dump: 65 66 --2017-01-10 12:31:22-- http://localhost:8080/ 67 Resolving localhost (localhost)... 127.0.0.1 68 Connecting to localhost (localhost)|127.0.0.1|:8080... connected. 69 HTTP request sent, awaiting response... 200 OK 70 Length: unspecified [text/html] 71 Saving to: '/dev/fd/3' 72 Crash dump id: <id> 73 74 On macOS when uploading a crash report to this script over HTTP you may 75 receive an error like the following: 76 77 "Transport security has blocked a cleartext HTTP (http://) resource load 78 since it is insecure. Temporary exceptions can be configured via your app's 79 Info.plist file." 80 81 You can work around this error by adding the following key to the Helper app 82 Info.plist file (e.g. "<appname>.app/Contents/Frameworks/ 83 <appname> Helper.app/Contents/Info.plist"): 84 85 <key>NSAppTransportSecurity</key> 86 <dict> 87 <!--Allow all connections (for testing only!)--> 88 <key>NSAllowsArbitraryLoads</key> 89 <true/> 90 </dict> 91 925. The cefclient sample application sets test crash key values in the browser 93 and renderer processes. To work properly these values must also be defined 94 in the "[CrashKeys]" section of "crash_reporter.cfg" as shown above. 95 96 In tests/cefclient/browser/client_browser.cc (browser process): 97 98 CefSetCrashKeyValue("testkey1", "value1_browser"); 99 CefSetCrashKeyValue("testkey2", "value2_browser"); 100 CefSetCrashKeyValue("testkey3", "value3_browser"); 101 102 In tests/cefclient/renderer/client_renderer.cc (renderer process): 103 104 CefSetCrashKeyValue("testkey1", "value1_renderer"); 105 CefSetCrashKeyValue("testkey2", "value2_renderer"); 106 CefSetCrashKeyValue("testkey3", "value3_renderer"); 107 108 When crashing the browser or renderer processes with cefclient you should 109 verify that the test crash key values are included in the metadata 110 ("<id>.json") file. Some values may be chunked as described in 111 include/cef_crash_util.h. 112""" 113 114from __future__ import absolute_import 115from __future__ import print_function 116import cgi 117import datetime 118import json 119import os 120import shutil 121import sys 122import uuid 123import zlib 124 125is_python2 = sys.version_info.major == 2 126 127if is_python2: 128 from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer 129 from cStringIO import StringIO as BytesIO 130else: 131 from http.server import BaseHTTPRequestHandler, HTTPServer 132 from io import BytesIO, open 133 134 135def print_msg(msg): 136 """ Write |msg| to stdout and flush. """ 137 timestr = datetime.datetime.now().strftime("%m/%d/%Y %H:%M:%S") 138 sys.stdout.write("%s: %s\n" % (timestr, msg)) 139 sys.stdout.flush() 140 141 142# Key identifying the minidump file. 143minidump_key = 'upload_file_minidump' 144 145 146class CrashHTTPRequestHandler(BaseHTTPRequestHandler): 147 148 def __init__(self, dump_directory, *args): 149 self._dump_directory = dump_directory 150 BaseHTTPRequestHandler.__init__(self, *args) 151 152 def _send_default_response_headers(self): 153 """ Send default response headers. """ 154 self.send_response(200) 155 self.send_header('Content-type', 'text/html') 156 self.end_headers() 157 158 def _parse_post_data(self, data): 159 """ Returns a cgi.FieldStorage object for this request or None if this is 160 not a POST request. """ 161 if self.command != 'POST': 162 return None 163 return cgi.FieldStorage( 164 fp=BytesIO(data), 165 headers=self.headers, 166 environ={ 167 'REQUEST_METHOD': 'POST', 168 'CONTENT_TYPE': self.headers['Content-Type'], 169 }) 170 171 def _get_chunk_size(self): 172 # Read to the next "\r\n". 173 size_str = self.rfile.read(2) 174 while size_str[-2:] != b"\r\n": 175 size_str += self.rfile.read(1) 176 # Remove the trailing "\r\n". 177 size_str = size_str[:-2] 178 assert len(size_str) <= 4 179 return int(size_str, 16) 180 181 def _get_chunk_data(self, chunk_size): 182 data = self.rfile.read(chunk_size) 183 assert len(data) == chunk_size 184 # Skip the trailing "\r\n". 185 self.rfile.read(2) 186 return data 187 188 def _unchunk_request(self, compressed): 189 """ Read a chunked request body. Optionally decompress the result. """ 190 if compressed: 191 d = zlib.decompressobj(16 + zlib.MAX_WBITS) 192 193 # Chunked format is: <size>\r\n<bytes>\r\n<size>\r\n<bytes>\r\n0\r\n 194 unchunked = b"" 195 while True: 196 chunk_size = self._get_chunk_size() 197 print('Chunk size 0x%x' % chunk_size) 198 if (chunk_size == 0): 199 break 200 chunk_data = self._get_chunk_data(chunk_size) 201 if compressed: 202 unchunked += d.decompress(chunk_data) 203 else: 204 unchunked += chunk_data 205 206 if compressed: 207 unchunked += d.flush() 208 209 return unchunked 210 211 def _create_new_dump_id(self): 212 """ Breakpad requires a 16 digit hexadecimal dump ID. """ 213 return uuid.uuid4().hex.upper()[0:16] 214 215 def do_GET(self): 216 """ Default empty implementation for handling GET requests. """ 217 self._send_default_response_headers() 218 self.wfile.write("<html><body><h1>GET!</h1></body></html>") 219 220 def do_HEAD(self): 221 """ Default empty implementation for handling HEAD requests. """ 222 self._send_default_response_headers() 223 224 def do_POST(self): 225 """ Handle a multi-part POST request submitted by Breakpad/Crashpad. """ 226 self._send_default_response_headers() 227 228 # Create a unique ID for the dump. 229 dump_id = self._create_new_dump_id() 230 231 # Return the unique ID to the caller. 232 self.wfile.write(dump_id.encode('utf-8')) 233 234 dmp_stream = None 235 metadata = {} 236 237 # Request body may be chunked and/or gzip compressed. For example: 238 # 239 # 3029 branch on Windows: 240 # User-Agent: Crashpad/0.8.0 241 # Host: localhost:8080 242 # Connection: Keep-Alive 243 # Transfer-Encoding: chunked 244 # Content-Type: multipart/form-data; boundary=---MultipartBoundary-vp5j9HdSRYK8DvX2DhtpqEbMNjSN1wnL--- 245 # Content-Encoding: gzip 246 # 247 # 2987 branch on Windows: 248 # User-Agent: Crashpad/0.8.0 249 # Host: localhost:8080 250 # Connection: Keep-Alive 251 # Content-Type: multipart/form-data; boundary=---MultipartBoundary-qFhorGA40vDJ1fgmc2mjorL0fRfKOqup--- 252 # Content-Length: 609894 253 # 254 # 2883 branch on Linux: 255 # User-Agent: Wget/1.15 (linux-gnu) 256 # Host: localhost:8080 257 # Accept: */* 258 # Connection: Keep-Alive 259 # Content-Type: multipart/form-data; boundary=--------------------------83572861f14cc736 260 # Content-Length: 32237 261 # Content-Encoding: gzip 262 print(self.headers) 263 264 chunked = 'Transfer-Encoding' in self.headers and self.headers['Transfer-Encoding'].lower( 265 ) == 'chunked' 266 compressed = 'Content-Encoding' in self.headers and self.headers['Content-Encoding'].lower( 267 ) == 'gzip' 268 if chunked: 269 request_body = self._unchunk_request(compressed) 270 else: 271 content_length = int(self.headers[ 272 'Content-Length']) if 'Content-Length' in self.headers else 0 273 if content_length > 0: 274 request_body = self.rfile.read(content_length) 275 else: 276 request_body = self.rfile.read() 277 if compressed: 278 request_body = zlib.decompress(request_body, 16 + zlib.MAX_WBITS) 279 280 # Parse the multi-part request. 281 form_data = self._parse_post_data(request_body) 282 for key in form_data.keys(): 283 if key == minidump_key and form_data[minidump_key].file: 284 dmp_stream = form_data[minidump_key].file 285 else: 286 metadata[key] = form_data[key].value 287 288 if dmp_stream is None: 289 # Exit early if the request is invalid. 290 print_msg('Invalid dump %s' % dump_id) 291 return 292 293 print_msg('Dump %s' % dump_id) 294 295 # Write the minidump to file. 296 dump_file = os.path.join(self._dump_directory, dump_id + '.dmp') 297 with open(dump_file, 'wb') as fp: 298 shutil.copyfileobj(dmp_stream, fp) 299 300 # Write the metadata to file. 301 meta_file = os.path.join(self._dump_directory, dump_id + '.json') 302 if is_python2: 303 with open(meta_file, 'w') as fp: 304 json.dump( 305 metadata, 306 fp, 307 ensure_ascii=False, 308 encoding='utf-8', 309 indent=2, 310 sort_keys=True) 311 else: 312 with open(meta_file, 'w', encoding='utf-8') as fp: 313 json.dump(metadata, fp, indent=2, sort_keys=True) 314 315 316def HandleRequestsUsing(dump_store): 317 return lambda *args: CrashHTTPRequestHandler(dump_directory, *args) 318 319 320def RunCrashServer(port, dump_directory): 321 """ Run the crash handler HTTP server. """ 322 httpd = HTTPServer(('', port), HandleRequestsUsing(dump_directory)) 323 print_msg('Starting httpd on port %d' % port) 324 httpd.serve_forever() 325 326 327# Program entry point. 328if __name__ == "__main__": 329 if len(sys.argv) != 3: 330 print('Usage: %s <port> <dump_directory>' % os.path.basename(sys.argv[0])) 331 sys.exit(1) 332 333 # Create the dump directory if necessary. 334 dump_directory = sys.argv[2] 335 if not os.path.exists(dump_directory): 336 os.makedirs(dump_directory) 337 if not os.path.isdir(dump_directory): 338 raise Exception('Directory does not exist: %s' % dump_directory) 339 340 RunCrashServer(int(sys.argv[1]), dump_directory) 341