1#!/usr/bin/python3 2 3# Copyright (C) 2019 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17# 18# This is an ADB proxy for Winscope. 19# 20# Requirements: python3.5 and ADB installed and in system PATH. 21# 22# Usage: 23# run: python3 winscope_proxy.py 24# 25 26import json 27import logging 28import os 29import re 30import secrets 31import signal 32import subprocess 33import sys 34import threading 35import time 36from abc import abstractmethod 37from enum import Enum 38from http import HTTPStatus 39from http.server import HTTPServer, BaseHTTPRequestHandler 40from tempfile import NamedTemporaryFile 41 42# CONFIG # 43 44LOG_LEVEL = logging.WARNING 45 46PORT = 5544 47 48# Keep in sync with WINSCOPE_PROXY_VERSION in Winscope DataAdb.vue 49VERSION = '0.5' 50 51WINSCOPE_VERSION_HEADER = "Winscope-Proxy-Version" 52WINSCOPE_TOKEN_HEADER = "Winscope-Token" 53 54# Location to save the proxy security token 55WINSCOPE_TOKEN_LOCATION = os.path.expanduser('~/.config/winscope/.token') 56 57# Max interval between the client keep-alive requests in seconds 58KEEP_ALIVE_INTERVAL_S = 5 59 60logging.basicConfig(stream=sys.stderr, level=LOG_LEVEL, 61 format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 62log = logging.getLogger("ADBProxy") 63 64 65class TraceTarget: 66 """Defines a single parameter to trace. 67 68 Attributes: 69 file: the path on the device the trace results are saved to. 70 trace_start: command to start the trace from adb shell, must not block. 71 trace_stop: command to stop the trace, should block until the trace is stopped. 72 """ 73 74 def __init__(self, file: str, trace_start: str, trace_stop: str) -> None: 75 self.file = file 76 self.trace_start = trace_start 77 self.trace_stop = trace_stop 78 79 80TRACE_TARGETS = { 81 "window_trace": TraceTarget( 82 "/data/misc/wmtrace/wm_trace.pb", 83 'su root cmd window tracing start\necho "WM trace started."', 84 'su root cmd window tracing stop >/dev/null 2>&1' 85 ), 86 "layers_trace": TraceTarget( 87 "/data/misc/wmtrace/layers_trace.pb", 88 'su root service call SurfaceFlinger 1025 i32 1\necho "SF trace started."', 89 'su root service call SurfaceFlinger 1025 i32 0 >/dev/null 2>&1' 90 ), 91 "screen_recording": TraceTarget( 92 "/data/local/tmp/screen.winscope.mp4", 93 'screenrecord --bit-rate 8M /data/local/tmp/screen.winscope.mp4 >/dev/null 2>&1 &\necho "ScreenRecorder started."', 94 'pkill -l SIGINT screenrecord >/dev/null 2>&1' 95 ), 96 "transaction": TraceTarget( 97 "/data/misc/wmtrace/transaction_trace.pb", 98 'su root service call SurfaceFlinger 1020 i32 1\necho "SF transactions recording started."', 99 'su root service call SurfaceFlinger 1020 i32 0 >/dev/null 2>&1' 100 ), 101 "proto_log": TraceTarget( 102 "/data/misc/wmtrace/wm_log.pb", 103 'su root cmd window logging start\necho "WM logging started."', 104 'su root cmd window logging stop >/dev/null 2>&1' 105 ), 106} 107 108 109class DumpTarget: 110 """Defines a single parameter to trace. 111 112 Attributes: 113 file: the path on the device the dump results are saved to. 114 dump_command: command to dump state to file. 115 """ 116 117 def __init__(self, file: str, dump_command: str) -> None: 118 self.file = file 119 self.dump_command = dump_command 120 121 122DUMP_TARGETS = { 123 "window_dump": DumpTarget( 124 "/data/local/tmp/wm_dump.pb", 125 'su root dumpsys window --proto > /data/local/tmp/wm_dump.pb' 126 ), 127 "layers_dump": DumpTarget( 128 "/data/local/tmp/sf_dump.pb", 129 'su root dumpsys SurfaceFlinger --proto > /data/local/tmp/sf_dump.pb' 130 ) 131} 132 133 134# END OF CONFIG # 135 136 137def get_token() -> str: 138 """Returns saved proxy security token or creates new one""" 139 try: 140 with open(WINSCOPE_TOKEN_LOCATION, 'r') as token_file: 141 token = token_file.readline() 142 log.debug("Loaded token {} from {}".format(token, WINSCOPE_TOKEN_LOCATION)) 143 return token 144 except IOError: 145 token = secrets.token_hex(32) 146 os.makedirs(os.path.dirname(WINSCOPE_TOKEN_LOCATION), exist_ok=True) 147 try: 148 with open(WINSCOPE_TOKEN_LOCATION, 'w') as token_file: 149 log.debug("Created and saved token {} to {}".format(token, WINSCOPE_TOKEN_LOCATION)) 150 token_file.write(token) 151 os.chmod(WINSCOPE_TOKEN_LOCATION, 0o600) 152 except IOError: 153 log.error("Unable to save persistent token {} to {}".format(token, WINSCOPE_TOKEN_LOCATION)) 154 return token 155 156 157secret_token = get_token() 158 159 160class RequestType(Enum): 161 GET = 1 162 POST = 2 163 HEAD = 3 164 165 166def add_standard_headers(server): 167 server.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') 168 server.send_header('Access-Control-Allow-Origin', '*') 169 server.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') 170 server.send_header('Access-Control-Allow-Headers', WINSCOPE_TOKEN_HEADER + ', Content-Type, Content-Length') 171 server.send_header('Access-Control-Expose-Headers', 'Winscope-Proxy-Version') 172 server.send_header(WINSCOPE_VERSION_HEADER, VERSION) 173 server.end_headers() 174 175 176class RequestEndpoint: 177 """Request endpoint to use with the RequestRouter.""" 178 179 @abstractmethod 180 def process(self, server, path): 181 pass 182 183 184class AdbError(Exception): 185 """Unsuccessful ADB operation""" 186 pass 187 188 189class BadRequest(Exception): 190 """Invalid client request""" 191 pass 192 193 194class RequestRouter: 195 """Handles HTTP request authenticationn and routing""" 196 197 def __init__(self, handler): 198 self.request = handler 199 self.endpoints = {} 200 201 def register_endpoint(self, method: RequestType, name: str, endpoint: RequestEndpoint): 202 self.endpoints[(method, name)] = endpoint 203 204 def __bad_request(self, error: str): 205 log.warning("Bad request: " + error) 206 self.request.respond(HTTPStatus.BAD_REQUEST, b"Bad request!\nThis is Winscope ADB proxy.\n\n" 207 + error.encode("utf-8"), 'text/txt') 208 209 def __internal_error(self, error: str): 210 log.error("Internal error: " + error) 211 self.request.respond(HTTPStatus.INTERNAL_SERVER_ERROR, error.encode("utf-8"), 'text/txt') 212 213 def __bad_token(self): 214 log.info("Bad token") 215 self.request.respond(HTTPStatus.FORBIDDEN, b"Bad Winscope authorisation token!\nThis is Winscope ADB proxy.\n", 216 'text/txt') 217 218 def process(self, method: RequestType): 219 token = self.request.headers[WINSCOPE_TOKEN_HEADER] 220 if not token or token != secret_token: 221 return self.__bad_token() 222 path = self.request.path.strip('/').split('/') 223 if path and len(path) > 0: 224 endpoint_name = path[0] 225 try: 226 return self.endpoints[(method, endpoint_name)].process(self.request, path[1:]) 227 except KeyError: 228 return self.__bad_request("Unknown endpoint /{}/".format(endpoint_name)) 229 except AdbError as ex: 230 return self.__internal_error(str(ex)) 231 except BadRequest as ex: 232 return self.__bad_request(str(ex)) 233 except Exception as ex: 234 return self.__internal_error(repr(ex)) 235 self.__bad_request("No endpoint specified") 236 237 238def call_adb(params: str, device: str = None, stdin: bytes = None): 239 command = ['adb'] + (['-s', device] if device else []) + params.split(' ') 240 try: 241 log.debug("Call: " + ' '.join(command)) 242 return subprocess.check_output(command, stderr=subprocess.STDOUT, input=stdin).decode('utf-8') 243 except OSError as ex: 244 log.debug('Error executing adb command: {}\n{}'.format(' '.join(command), repr(ex))) 245 raise AdbError('Error executing adb command: {}\n{}'.format(' '.join(command), repr(ex))) 246 except subprocess.CalledProcessError as ex: 247 log.debug('Error executing adb command: {}\n{}'.format(' '.join(command), ex.output.decode("utf-8"))) 248 raise AdbError('Error executing adb command: adb {}\n{}'.format(params, ex.output.decode("utf-8"))) 249 250 251def call_adb_outfile(params: str, outfile, device: str = None, stdin: bytes = None): 252 try: 253 process = subprocess.Popen(['adb'] + (['-s', device] if device else []) + params.split(' '), stdout=outfile, 254 stderr=subprocess.PIPE) 255 _, err = process.communicate(stdin) 256 outfile.seek(0) 257 if process.returncode != 0: 258 log.debug('Error executing adb command: adb {}\n'.format(params) + err.decode( 259 'utf-8') + '\n' + outfile.read().decode('utf-8')) 260 raise AdbError('Error executing adb command: adb {}\n'.format(params) + err.decode( 261 'utf-8') + '\n' + outfile.read().decode('utf-8')) 262 except OSError as ex: 263 log.debug('Error executing adb command: adb {}\n{}'.format(params, repr(ex))) 264 raise AdbError('Error executing adb command: adb {}\n{}'.format(params, repr(ex))) 265 266 267class ListDevicesEndpoint(RequestEndpoint): 268 ADB_INFO_RE = re.compile("^([A-Za-z0-9\\-]+)\\s+(\\w+)(.*model:(\\w+))?") 269 270 def process(self, server, path): 271 lines = list(filter(None, call_adb('devices -l').split('\n'))) 272 devices = {m.group(1): { 273 'authorised': str(m.group(2)) != 'unauthorized', 274 'model': m.group(4).replace('_', ' ') if m.group(4) else '' 275 } for m in [ListDevicesEndpoint.ADB_INFO_RE.match(d) for d in lines[1:]] if m} 276 j = json.dumps(devices) 277 log.debug("Detected devices: " + j) 278 server.respond(HTTPStatus.OK, j.encode("utf-8"), "text/json") 279 280 281class DeviceRequestEndpoint(RequestEndpoint): 282 def process(self, server, path): 283 if len(path) > 0 and re.fullmatch("[A-Za-z0-9\\-]+", path[0]): 284 self.process_with_device(server, path[1:], path[0]) 285 else: 286 raise BadRequest("Device id not specified") 287 288 @abstractmethod 289 def process_with_device(self, server, path, device_id): 290 pass 291 292 293class FetchFileEndpoint(DeviceRequestEndpoint): 294 def process_with_device(self, server, path, device_id): 295 if len(path) != 1: 296 raise BadRequest("File not specified") 297 if path[0] in TRACE_TARGETS: 298 file_path = TRACE_TARGETS[path[0]].file 299 elif path[0] in DUMP_TARGETS: 300 file_path = DUMP_TARGETS[path[0]].file 301 else: 302 raise BadRequest("Unknown file specified") 303 304 with NamedTemporaryFile() as tmp: 305 log.debug("Fetching file {} from device to {}".format(file_path, tmp.name)) 306 call_adb_outfile('exec-out su root cat ' + file_path, tmp, device_id) 307 log.debug("Deleting file {} from device".format(file_path)) 308 call_adb('shell su root rm ' + file_path, device_id) 309 server.send_response(HTTPStatus.OK) 310 server.send_header('X-Content-Type-Options', 'nosniff') 311 server.send_header('Content-type', 'application/octet-stream') 312 add_standard_headers(server) 313 log.debug("Uploading file {}".format(tmp.name)) 314 while True: 315 buf = tmp.read(1024) 316 if buf: 317 server.wfile.write(buf) 318 else: 319 break 320 321 322def check_root(device_id): 323 log.debug("Checking root access on {}".format(device_id)) 324 return call_adb('shell su root id -u', device_id) == "0\n" 325 326 327TRACE_THREADS = {} 328 329 330class TraceThread(threading.Thread): 331 def __init__(self, device_id, command): 332 self._keep_alive_timer = None 333 self.trace_command = command 334 self._device_id = device_id 335 self.out = None, 336 self.err = None, 337 self._success = False 338 try: 339 shell = ['adb', '-s', self._device_id, 'shell'] 340 log.debug("Starting trace shell {}".format(' '.join(shell))) 341 self.process = subprocess.Popen(shell, stdout=subprocess.PIPE, 342 stderr=subprocess.PIPE, stdin=subprocess.PIPE, start_new_session=True) 343 except OSError as ex: 344 raise AdbError('Error executing adb command: adb shell\n{}'.format(repr(ex))) 345 346 super().__init__() 347 348 def timeout(self): 349 if self.is_alive(): 350 log.warning("Keep-alive timeout for trace on {}".format(self._device_id)) 351 self.end_trace() 352 if self._device_id in TRACE_THREADS: 353 TRACE_THREADS.pop(self._device_id) 354 355 def reset_timer(self): 356 log.debug("Resetting keep-alive clock for trace on {}".format(self._device_id)) 357 if self._keep_alive_timer: 358 self._keep_alive_timer.cancel() 359 self._keep_alive_timer = threading.Timer(KEEP_ALIVE_INTERVAL_S, self.timeout) 360 self._keep_alive_timer.start() 361 362 def end_trace(self): 363 if self._keep_alive_timer: 364 self._keep_alive_timer.cancel() 365 log.debug("Sending SIGINT to the trace process on {}".format(self._device_id)) 366 self.process.send_signal(signal.SIGINT) 367 try: 368 log.debug("Waiting for trace shell to exit for {}".format(self._device_id)) 369 self.process.wait(timeout=5) 370 except TimeoutError: 371 log.debug("TIMEOUT - sending SIGKILL to the trace process on {}".format(self._device_id)) 372 self.process.kill() 373 self.join() 374 375 def run(self): 376 log.debug("Trace started on {}".format(self._device_id)) 377 self.reset_timer() 378 self.out, self.err = self.process.communicate(self.trace_command) 379 log.debug("Trace ended on {}, waiting for cleanup".format(self._device_id)) 380 time.sleep(0.2) 381 for i in range(10): 382 if call_adb("shell su root cat /data/local/tmp/winscope_status", device=self._device_id) == 'TRACE_OK\n': 383 call_adb("shell su root rm /data/local/tmp/winscope_status", device=self._device_id) 384 log.debug("Trace finished successfully on {}".format(self._device_id)) 385 self._success = True 386 break 387 log.debug("Still waiting for cleanup on {}".format(self._device_id)) 388 time.sleep(0.1) 389 390 def success(self): 391 return self._success 392 393 394class StartTrace(DeviceRequestEndpoint): 395 TRACE_COMMAND = """ 396set -e 397 398echo "Starting trace..." 399echo "TRACE_START" > /data/local/tmp/winscope_status 400 401# Do not print anything to stdout/stderr in the handler 402function stop_trace() {{ 403 trap - EXIT HUP INT 404 405{} 406 407 echo "TRACE_OK" > /data/local/tmp/winscope_status 408}} 409 410trap stop_trace EXIT HUP INT 411echo "Signal handler registered." 412 413{} 414 415# ADB shell does not handle hung up well and does not call HUP handler when a child is active in foreground, 416# as a workaround we sleep for short intervals in a loop so the handler is called after a sleep interval. 417while true; do sleep 0.1; done 418""" 419 420 def process_with_device(self, server, path, device_id): 421 try: 422 length = int(server.headers["Content-Length"]) 423 except KeyError as err: 424 raise BadRequest("Missing Content-Length header\n" + str(err)) 425 except ValueError as err: 426 raise BadRequest("Content length unreadable\n" + str(err)) 427 try: 428 requested_types = json.loads(server.rfile.read(length).decode("utf-8")) 429 requested_traces = [TRACE_TARGETS[t] for t in requested_types] 430 except KeyError as err: 431 raise BadRequest("Unsupported trace target\n" + str(err)) 432 if device_id in TRACE_THREADS: 433 log.warning("Trace already in progress for {}", device_id) 434 server.respond(HTTPStatus.OK, b'', "text/plain") 435 if not check_root(device_id): 436 raise AdbError( 437 "Unable to acquire root privileges on the device - check the output of 'adb -s {} shell su root id'".format( 438 device_id)) 439 command = StartTrace.TRACE_COMMAND.format( 440 '\n'.join([t.trace_stop for t in requested_traces]), 441 '\n'.join([t.trace_start for t in requested_traces])) 442 log.debug("Trace requested for {} with targets {}".format(device_id, ','.join(requested_types))) 443 TRACE_THREADS[device_id] = TraceThread(device_id, command.encode('utf-8')) 444 TRACE_THREADS[device_id].start() 445 server.respond(HTTPStatus.OK, b'', "text/plain") 446 447 448class EndTrace(DeviceRequestEndpoint): 449 def process_with_device(self, server, path, device_id): 450 if device_id not in TRACE_THREADS: 451 raise BadRequest("No trace in progress for {}".format(device_id)) 452 if TRACE_THREADS[device_id].is_alive(): 453 TRACE_THREADS[device_id].end_trace() 454 455 success = TRACE_THREADS[device_id].success() 456 out = TRACE_THREADS[device_id].out + b"\n" + TRACE_THREADS[device_id].err 457 command = TRACE_THREADS[device_id].trace_command 458 TRACE_THREADS.pop(device_id) 459 if success: 460 server.respond(HTTPStatus.OK, out, "text/plain") 461 else: 462 raise AdbError( 463 "Error tracing the device\n### Output ###\n" + out.decode( 464 "utf-8") + "\n### Command: adb -s {} shell ###\n### Input ###\n".format(device_id) + command.decode( 465 "utf-8")) 466 467 468class StatusEndpoint(DeviceRequestEndpoint): 469 def process_with_device(self, server, path, device_id): 470 if device_id not in TRACE_THREADS: 471 raise BadRequest("No trace in progress for {}".format(device_id)) 472 TRACE_THREADS[device_id].reset_timer() 473 server.respond(HTTPStatus.OK, str(TRACE_THREADS[device_id].is_alive()).encode("utf-8"), "text/plain") 474 475 476class DumpEndpoint(DeviceRequestEndpoint): 477 def process_with_device(self, server, path, device_id): 478 try: 479 length = int(server.headers["Content-Length"]) 480 except KeyError as err: 481 raise BadRequest("Missing Content-Length header\n" + str(err)) 482 except ValueError as err: 483 raise BadRequest("Content length unreadable\n" + str(err)) 484 try: 485 requested_types = json.loads(server.rfile.read(length).decode("utf-8")) 486 requested_traces = [DUMP_TARGETS[t] for t in requested_types] 487 except KeyError as err: 488 raise BadRequest("Unsupported trace target\n" + str(err)) 489 if device_id in TRACE_THREADS: 490 BadRequest("Trace in progress for {}".format(device_id)) 491 if not check_root(device_id): 492 raise AdbError( 493 "Unable to acquire root privileges on the device - check the output of 'adb -s {} shell su root id'" 494 .format(device_id)) 495 command = '\n'.join(t.dump_command for t in requested_traces) 496 shell = ['adb', '-s', device_id, 'shell'] 497 log.debug("Starting dump shell {}".format(' '.join(shell))) 498 process = subprocess.Popen(shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 499 stdin=subprocess.PIPE, start_new_session=True) 500 log.debug("Starting dump on device {}".format(device_id)) 501 out, err = process.communicate(command.encode('utf-8')) 502 if process.returncode != 0: 503 raise AdbError("Error executing command:\n" + command + "\n\n### OUTPUT ###" + out.decode('utf-8') + "\n" 504 + err.decode('utf-8')) 505 log.debug("Dump finished on device {}".format(device_id)) 506 server.respond(HTTPStatus.OK, b'', "text/plain") 507 508 509class ADBWinscopeProxy(BaseHTTPRequestHandler): 510 def __init__(self, request, client_address, server): 511 self.router = RequestRouter(self) 512 self.router.register_endpoint(RequestType.GET, "devices", ListDevicesEndpoint()) 513 self.router.register_endpoint(RequestType.GET, "status", StatusEndpoint()) 514 self.router.register_endpoint(RequestType.GET, "fetch", FetchFileEndpoint()) 515 self.router.register_endpoint(RequestType.POST, "start", StartTrace()) 516 self.router.register_endpoint(RequestType.POST, "end", EndTrace()) 517 self.router.register_endpoint(RequestType.POST, "dump", DumpEndpoint()) 518 super().__init__(request, client_address, server) 519 520 def respond(self, code: int, data: bytes, mime: str) -> None: 521 self.send_response(code) 522 self.send_header('Content-type', mime) 523 add_standard_headers(self) 524 self.wfile.write(data) 525 526 def do_GET(self): 527 self.router.process(RequestType.GET) 528 529 def do_POST(self): 530 self.router.process(RequestType.POST) 531 532 def do_OPTIONS(self): 533 self.send_response(HTTPStatus.OK) 534 self.send_header('Allow', 'GET,POST') 535 add_standard_headers(self) 536 self.end_headers() 537 self.wfile.write(b'GET,POST') 538 539 def log_request(self, code='-', size='-'): 540 log.info('{} {} {}'.format(self.requestline, str(code), str(size))) 541 542 543if __name__ == '__main__': 544 print("Winscope ADB Connect proxy version: " + VERSION) 545 print('Winscope token: ' + secret_token) 546 httpd = HTTPServer(('localhost', PORT), ADBWinscopeProxy) 547 try: 548 httpd.serve_forever() 549 except KeyboardInterrupt: 550 log.info("Shutting down") 551