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