• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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