1#!/usr/bin/python2 2# 3# Copyright (C) 2017 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"""Send an A/B update to an Android device over adb.""" 19 20import argparse 21import BaseHTTPServer 22import hashlib 23import logging 24import os 25import socket 26import subprocess 27import sys 28import threading 29import xml.etree.ElementTree 30import zipfile 31 32import update_payload.payload 33 34 35# The path used to store the OTA package when applying the package from a file. 36OTA_PACKAGE_PATH = '/data/ota_package' 37 38# The path to the payload public key on the device. 39PAYLOAD_KEY_PATH = '/etc/update_engine/update-payload-key.pub.pem' 40 41# The port on the device that update_engine should connect to. 42DEVICE_PORT = 1234 43 44def CopyFileObjLength(fsrc, fdst, buffer_size=128 * 1024, copy_length=None): 45 """Copy from a file object to another. 46 47 This function is similar to shutil.copyfileobj except that it allows to copy 48 less than the full source file. 49 50 Args: 51 fsrc: source file object where to read from. 52 fdst: destination file object where to write to. 53 buffer_size: size of the copy buffer in memory. 54 copy_length: maximum number of bytes to copy, or None to copy everything. 55 56 Returns: 57 the number of bytes copied. 58 """ 59 copied = 0 60 while True: 61 chunk_size = buffer_size 62 if copy_length is not None: 63 chunk_size = min(chunk_size, copy_length - copied) 64 if not chunk_size: 65 break 66 buf = fsrc.read(chunk_size) 67 if not buf: 68 break 69 fdst.write(buf) 70 copied += len(buf) 71 return copied 72 73 74class AndroidOTAPackage(object): 75 """Android update payload using the .zip format. 76 77 Android OTA packages traditionally used a .zip file to store the payload. When 78 applying A/B updates over the network, a payload binary is stored RAW inside 79 this .zip file which is used by update_engine to apply the payload. To do 80 this, an offset and size inside the .zip file are provided. 81 """ 82 83 # Android OTA package file paths. 84 OTA_PAYLOAD_BIN = 'payload.bin' 85 OTA_PAYLOAD_PROPERTIES_TXT = 'payload_properties.txt' 86 87 def __init__(self, otafilename): 88 self.otafilename = otafilename 89 90 otazip = zipfile.ZipFile(otafilename, 'r') 91 payload_info = otazip.getinfo(self.OTA_PAYLOAD_BIN) 92 self.offset = payload_info.header_offset 93 self.offset += zipfile.sizeFileHeader 94 self.offset += len(payload_info.extra) + len(payload_info.filename) 95 self.size = payload_info.file_size 96 self.properties = otazip.read(self.OTA_PAYLOAD_PROPERTIES_TXT) 97 98 99class UpdateHandler(BaseHTTPServer.BaseHTTPRequestHandler): 100 """A HTTPServer that supports single-range requests. 101 102 Attributes: 103 serving_payload: path to the only payload file we are serving. 104 serving_range: the start offset and size tuple of the payload. 105 """ 106 107 @staticmethod 108 def _parse_range(range_str, file_size): 109 """Parse an HTTP range string. 110 111 Args: 112 range_str: HTTP Range header in the request, not including "Header:". 113 file_size: total size of the serving file. 114 115 Returns: 116 A tuple (start_range, end_range) with the range of bytes requested. 117 """ 118 start_range = 0 119 end_range = file_size 120 121 if range_str: 122 range_str = range_str.split('=', 1)[1] 123 s, e = range_str.split('-', 1) 124 if s: 125 start_range = int(s) 126 if e: 127 end_range = int(e) + 1 128 elif e: 129 if int(e) < file_size: 130 start_range = file_size - int(e) 131 return start_range, end_range 132 133 134 def do_GET(self): # pylint: disable=invalid-name 135 """Reply with the requested payload file.""" 136 if self.path != '/payload': 137 self.send_error(404, 'Unknown request') 138 return 139 140 if not self.serving_payload: 141 self.send_error(500, 'No serving payload set') 142 return 143 144 try: 145 f = open(self.serving_payload, 'rb') 146 except IOError: 147 self.send_error(404, 'File not found') 148 return 149 # Handle the range request. 150 if 'Range' in self.headers: 151 self.send_response(206) 152 else: 153 self.send_response(200) 154 155 serving_start, serving_size = self.serving_range 156 start_range, end_range = self._parse_range(self.headers.get('range'), 157 serving_size) 158 logging.info('Serving request for %s from %s [%d, %d) length: %d', 159 self.path, self.serving_payload, serving_start + start_range, 160 serving_start + end_range, end_range - start_range) 161 162 self.send_header('Accept-Ranges', 'bytes') 163 self.send_header('Content-Range', 164 'bytes ' + str(start_range) + '-' + str(end_range - 1) + 165 '/' + str(end_range - start_range)) 166 self.send_header('Content-Length', end_range - start_range) 167 168 stat = os.fstat(f.fileno()) 169 self.send_header('Last-Modified', self.date_time_string(stat.st_mtime)) 170 self.send_header('Content-type', 'application/octet-stream') 171 self.end_headers() 172 173 f.seek(serving_start + start_range) 174 CopyFileObjLength(f, self.wfile, copy_length=end_range - start_range) 175 176 177 def do_POST(self): # pylint: disable=invalid-name 178 """Reply with the omaha response xml.""" 179 if self.path != '/update': 180 self.send_error(404, 'Unknown request') 181 return 182 183 if not self.serving_payload: 184 self.send_error(500, 'No serving payload set') 185 return 186 187 try: 188 f = open(self.serving_payload, 'rb') 189 except IOError: 190 self.send_error(404, 'File not found') 191 return 192 193 content_length = int(self.headers.getheader('Content-Length')) 194 request_xml = self.rfile.read(content_length) 195 xml_root = xml.etree.ElementTree.fromstring(request_xml) 196 appid = None 197 for app in xml_root.iter('app'): 198 if 'appid' in app.attrib: 199 appid = app.attrib['appid'] 200 break 201 if not appid: 202 self.send_error(400, 'No appid in Omaha request') 203 return 204 205 self.send_response(200) 206 self.send_header("Content-type", "text/xml") 207 self.end_headers() 208 209 serving_start, serving_size = self.serving_range 210 sha256 = hashlib.sha256() 211 f.seek(serving_start) 212 bytes_to_hash = serving_size 213 while bytes_to_hash: 214 buf = f.read(min(bytes_to_hash, 1024 * 1024)) 215 if not buf: 216 self.send_error(500, 'Payload too small') 217 return 218 sha256.update(buf) 219 bytes_to_hash -= len(buf) 220 221 payload = update_payload.Payload(f, payload_file_offset=serving_start) 222 payload.Init() 223 224 response_xml = ''' 225 <?xml version="1.0" encoding="UTF-8"?> 226 <response protocol="3.0"> 227 <app appid="{appid}"> 228 <updatecheck status="ok"> 229 <urls> 230 <url codebase="http://127.0.0.1:{port}/"/> 231 </urls> 232 <manifest version="0.0.0.1"> 233 <actions> 234 <action event="install" run="payload"/> 235 <action event="postinstall" MetadataSize="{metadata_size}"/> 236 </actions> 237 <packages> 238 <package hash_sha256="{payload_hash}" name="payload" size="{payload_size}"/> 239 </packages> 240 </manifest> 241 </updatecheck> 242 </app> 243 </response> 244 '''.format(appid=appid, port=DEVICE_PORT, 245 metadata_size=payload.metadata_size, 246 payload_hash=sha256.hexdigest(), 247 payload_size=serving_size) 248 self.wfile.write(response_xml.strip()) 249 return 250 251 252class ServerThread(threading.Thread): 253 """A thread for serving HTTP requests.""" 254 255 def __init__(self, ota_filename, serving_range): 256 threading.Thread.__init__(self) 257 # serving_payload and serving_range are class attributes and the 258 # UpdateHandler class is instantiated with every request. 259 UpdateHandler.serving_payload = ota_filename 260 UpdateHandler.serving_range = serving_range 261 self._httpd = BaseHTTPServer.HTTPServer(('127.0.0.1', 0), UpdateHandler) 262 self.port = self._httpd.server_port 263 264 def run(self): 265 try: 266 self._httpd.serve_forever() 267 except (KeyboardInterrupt, socket.error): 268 pass 269 logging.info('Server Terminated') 270 271 def StopServer(self): 272 self._httpd.socket.close() 273 274 275def StartServer(ota_filename, serving_range): 276 t = ServerThread(ota_filename, serving_range) 277 t.start() 278 return t 279 280 281def AndroidUpdateCommand(ota_filename, payload_url, extra_headers): 282 """Return the command to run to start the update in the Android device.""" 283 ota = AndroidOTAPackage(ota_filename) 284 headers = ota.properties 285 headers += 'USER_AGENT=Dalvik (something, something)\n' 286 headers += 'NETWORK_ID=0\n' 287 headers += extra_headers 288 289 return ['update_engine_client', '--update', '--follow', 290 '--payload=%s' % payload_url, '--offset=%d' % ota.offset, 291 '--size=%d' % ota.size, '--headers="%s"' % headers] 292 293 294def OmahaUpdateCommand(omaha_url): 295 """Return the command to run to start the update in a device using Omaha.""" 296 return ['update_engine_client', '--update', '--follow', 297 '--omaha_url=%s' % omaha_url] 298 299 300class AdbHost(object): 301 """Represents a device connected via ADB.""" 302 303 def __init__(self, device_serial=None): 304 """Construct an instance. 305 306 Args: 307 device_serial: options string serial number of attached device. 308 """ 309 self._device_serial = device_serial 310 self._command_prefix = ['adb'] 311 if self._device_serial: 312 self._command_prefix += ['-s', self._device_serial] 313 314 def adb(self, command): 315 """Run an ADB command like "adb push". 316 317 Args: 318 command: list of strings containing command and arguments to run 319 320 Returns: 321 the program's return code. 322 323 Raises: 324 subprocess.CalledProcessError on command exit != 0. 325 """ 326 command = self._command_prefix + command 327 logging.info('Running: %s', ' '.join(str(x) for x in command)) 328 p = subprocess.Popen(command, universal_newlines=True) 329 p.wait() 330 return p.returncode 331 332 def adb_output(self, command): 333 """Run an ADB command like "adb push" and return the output. 334 335 Args: 336 command: list of strings containing command and arguments to run 337 338 Returns: 339 the program's output as a string. 340 341 Raises: 342 subprocess.CalledProcessError on command exit != 0. 343 """ 344 command = self._command_prefix + command 345 logging.info('Running: %s', ' '.join(str(x) for x in command)) 346 return subprocess.check_output(command, universal_newlines=True) 347 348 349def main(): 350 parser = argparse.ArgumentParser(description='Android A/B OTA helper.') 351 parser.add_argument('otafile', metavar='PAYLOAD', type=str, 352 help='the OTA package file (a .zip file) or raw payload \ 353 if device uses Omaha.') 354 parser.add_argument('--file', action='store_true', 355 help='Push the file to the device before updating.') 356 parser.add_argument('--no-push', action='store_true', 357 help='Skip the "push" command when using --file') 358 parser.add_argument('-s', type=str, default='', metavar='DEVICE', 359 help='The specific device to use.') 360 parser.add_argument('--no-verbose', action='store_true', 361 help='Less verbose output') 362 parser.add_argument('--public-key', type=str, default='', 363 help='Override the public key used to verify payload.') 364 parser.add_argument('--extra-headers', type=str, default='', 365 help='Extra headers to pass to the device.') 366 args = parser.parse_args() 367 logging.basicConfig( 368 level=logging.WARNING if args.no_verbose else logging.INFO) 369 370 dut = AdbHost(args.s) 371 372 server_thread = None 373 # List of commands to execute on exit. 374 finalize_cmds = [] 375 # Commands to execute when canceling an update. 376 cancel_cmd = ['shell', 'su', '0', 'update_engine_client', '--cancel'] 377 # List of commands to perform the update. 378 cmds = [] 379 380 help_cmd = ['shell', 'su', '0', 'update_engine_client', '--help'] 381 use_omaha = 'omaha' in dut.adb_output(help_cmd) 382 383 if args.file: 384 # Update via pushing a file to /data. 385 device_ota_file = os.path.join(OTA_PACKAGE_PATH, 'debug.zip') 386 payload_url = 'file://' + device_ota_file 387 if not args.no_push: 388 data_local_tmp_file = '/data/local/tmp/debug.zip' 389 cmds.append(['push', args.otafile, data_local_tmp_file]) 390 cmds.append(['shell', 'su', '0', 'mv', data_local_tmp_file, 391 device_ota_file]) 392 cmds.append(['shell', 'su', '0', 'chcon', 393 'u:object_r:ota_package_file:s0', device_ota_file]) 394 cmds.append(['shell', 'su', '0', 'chown', 'system:cache', device_ota_file]) 395 cmds.append(['shell', 'su', '0', 'chmod', '0660', device_ota_file]) 396 else: 397 # Update via sending the payload over the network with an "adb reverse" 398 # command. 399 payload_url = 'http://127.0.0.1:%d/payload' % DEVICE_PORT 400 if use_omaha and zipfile.is_zipfile(args.otafile): 401 ota = AndroidOTAPackage(args.otafile) 402 serving_range = (ota.offset, ota.size) 403 else: 404 serving_range = (0, os.stat(args.otafile).st_size) 405 server_thread = StartServer(args.otafile, serving_range) 406 cmds.append( 407 ['reverse', 'tcp:%d' % DEVICE_PORT, 'tcp:%d' % server_thread.port]) 408 finalize_cmds.append(['reverse', '--remove', 'tcp:%d' % DEVICE_PORT]) 409 410 if args.public_key: 411 payload_key_dir = os.path.dirname(PAYLOAD_KEY_PATH) 412 cmds.append( 413 ['shell', 'su', '0', 'mount', '-t', 'tmpfs', 'tmpfs', payload_key_dir]) 414 # Allow adb push to payload_key_dir 415 cmds.append(['shell', 'su', '0', 'chcon', 'u:object_r:shell_data_file:s0', 416 payload_key_dir]) 417 cmds.append(['push', args.public_key, PAYLOAD_KEY_PATH]) 418 # Allow update_engine to read it. 419 cmds.append(['shell', 'su', '0', 'chcon', '-R', 'u:object_r:system_file:s0', 420 payload_key_dir]) 421 finalize_cmds.append(['shell', 'su', '0', 'umount', payload_key_dir]) 422 423 try: 424 # The main update command using the configured payload_url. 425 if use_omaha: 426 update_cmd = \ 427 OmahaUpdateCommand('http://127.0.0.1:%d/update' % DEVICE_PORT) 428 else: 429 update_cmd = \ 430 AndroidUpdateCommand(args.otafile, payload_url, args.extra_headers) 431 cmds.append(['shell', 'su', '0'] + update_cmd) 432 433 for cmd in cmds: 434 dut.adb(cmd) 435 except KeyboardInterrupt: 436 dut.adb(cancel_cmd) 437 finally: 438 if server_thread: 439 server_thread.StopServer() 440 for cmd in finalize_cmds: 441 dut.adb(cmd) 442 443 return 0 444 445if __name__ == '__main__': 446 sys.exit(main()) 447