| #!/usr/bin/env python2 |
| # -*- coding: utf-8 -*- |
| # Copyright 2018 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Mock Omaha server""" |
| |
| from __future__ import print_function |
| |
| # pylint: disable=cros-logging-import |
| import argparse |
| import copy |
| import json |
| import logging |
| import os |
| import sys |
| import threading |
| |
| from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer |
| from enum import Enum |
| from xml.etree import ElementTree |
| |
| |
| def VersionCmp(version_a_str, version_b_str): |
| """Compare two version strings. |
| |
| Currently we only match on major/minor versions. |
| |
| Args: |
| version_a_str: String representing first version number. |
| version_b_str: String representing second version number. |
| |
| Returns: |
| < 0 if version_a is less than version_b |
| > 0 if version_a is greater than version_b |
| 0 if the version numbers are equal |
| |
| Raises: |
| ValueError if either version string is not valid |
| """ |
| |
| try: |
| version_a = tuple([int(i) for i in version_a_str.split('.')[0:2]]) |
| version_b = tuple([int(i) for i in version_b_str.split('.')[0:2]]) |
| |
| if version_a[0] != version_b[0]: |
| return version_a[0] - version_b[0] |
| |
| return version_a[1] - version_b[1] |
| |
| except (IndexError, ValueError): |
| raise ValueError("Not a valid version string") |
| |
| |
| class Request(object): |
| """Request consisting of a list of apps to update/install.""" |
| |
| APP_TAG = 'app' |
| APPID_ATTR = 'appid' |
| VERSION_ATTR = 'version' |
| DELTA_OKAY_ATTR = 'delta_okay' |
| HW_CLASS_ATTR = 'hardware_class' |
| UPDATE_CHECK_TAG = 'updatecheck' |
| PING_TAG = 'ping' |
| EVENT_TAG = 'event' |
| EVENT_TYPE_ATTR = 'eventtype' |
| EVENT_RESULT_ATTR = 'eventresult' |
| |
| def __init__(self, request_str): |
| """Initializes a request instance. |
| |
| Args: |
| request_str: XML-formatted request string. |
| """ |
| self.request_str = request_str |
| |
| def ParseRequest(self): |
| """Parse an XML request string into a list of app requests. |
| |
| An app request can be a no-op, an install request, or an update request, and |
| may include a ping and/or event tag. We treat app requests with the update |
| tag omitted as no-ops, since the server is not required to return payload |
| information. Install requests are signalled by sending app requests along |
| with a no-op request for the platform app. |
| |
| Returns: |
| A list of AppRequest instances. |
| |
| Raises: |
| ValueError if the request string is not a valid XML request. |
| """ |
| try: |
| request_root = ElementTree.fromstring(self.request_str) |
| except ElementTree.ParseError as err: |
| logging.error("Request string is not valid XML (%s)", str(err)) |
| raise ValueError |
| |
| # An install is signalled by omitting the update check for the platform |
| # app, which can be found based on the presense of a hardware_class tag, |
| # which is absent on DLC update and install requests. |
| platform_app = next(iter([x for x in request_root.findall(self.APP_TAG) if |
| x.get(self.HW_CLASS_ATTR) is not None]), None) |
| if platform_app is not None: |
| is_install = platform_app.find(self.UPDATE_CHECK_TAG) is None |
| else: |
| is_install = False |
| |
| app_requests = [] |
| for app in request_root.findall(self.APP_TAG): |
| appid = app.get(self.APPID_ATTR) |
| version = app.get(self.VERSION_ATTR) |
| delta_okay = app.get(self.DELTA_OKAY_ATTR) == "true" |
| |
| event = app.find(self.EVENT_TAG) |
| if event is not None: |
| event_type = event.get(self.EVENT_TYPE_ATTR) |
| event_result = event.get(self.EVENT_RESULT_ATTR, 0) |
| else: |
| event_type = None |
| event_result = None |
| |
| ping = app.find(self.PING_TAG) is not None |
| |
| if app.find(self.UPDATE_CHECK_TAG) is not None: |
| if is_install: |
| request_type = Request.AppRequest.RequestType.INSTALL |
| else: |
| request_type = Request.AppRequest.RequestType.UPDATE |
| else: |
| request_type = Request.AppRequest.RequestType.NO_OP |
| |
| app_request = Request.AppRequest( |
| request_type=request_type, |
| appid=appid, |
| ping=ping, |
| version=version, |
| delta_okay=delta_okay, |
| event_type=event_type, |
| event_result=event_result) |
| |
| if not app_request.IsValid(): |
| raise ValueError("Invalid request: %s", str(app_request)) |
| |
| app_requests.append(app_request) |
| |
| return app_requests |
| |
| class AppRequest(object): |
| """An app request. |
| |
| Can be an update request, install request, or neither if the update check |
| tag is omitted (i.e. the platform app when installing a DLC, or when a |
| request is only an event), in which case we treat the request as a no-op. |
| An app request can also send pings and event result information. |
| """ |
| |
| RequestType = Enum("RequestType", "INSTALL UPDATE NO_OP") |
| |
| def __init__(self, request_type, appid, ping=False, version=None, |
| delta_okay=None, event_type=None, event_result=None): |
| """Initializes a Request. |
| |
| Args: |
| request_type: install, update, or no-op. |
| appid: The requested appid. |
| ping: True if the server should respond to a ping. |
| version: Current Chrome OS version. |
| delta_okay: True if an update request can accept a delta update. |
| event_type: Type of event. |
| event_result: Event result. |
| |
| More on event pings: |
| https://github.com/google/omaha/blob/master/doc/ServerProtocolV3.md |
| """ |
| self.request_type = request_type |
| self.appid = appid |
| self.ping = ping |
| self.version = version |
| self.delta_okay = delta_okay |
| self.event_type = event_type |
| self.event_result = event_result |
| |
| def __str__(self): |
| """Returns a string representation of an AppRequest.""" |
| if self.request_type == self.RequestType.NO_OP: |
| return "{}".format(self.appid) |
| elif self.request_type == self.RequestType.INSTALL: |
| return "install {} v{}".format(self.appid, self.version) |
| elif self.request_type == self.RequestType.UPDATE: |
| return "{} update {} from v{}".format( |
| "delta" if self.delta_okay else "full", self.appid, self.version) |
| |
| def IsValid(self): |
| """Returns true if an AppRequest is valid, False otherwise.""" |
| return None not in (self.request_type, self.appid, self.version) |
| |
| class AppData(object): |
| """Data about an available app. |
| |
| Data about an available app that can be either installed or upgraded to. This |
| information is compiled into XML format and returned to the client in an app |
| tag in the server's response to an update or install request. |
| """ |
| |
| APPID_KEY = 'appid' |
| NAME_KEY = 'name' |
| IS_DELTA_KEY = 'is_delta' |
| SIZE_KEY = 'size' |
| METADATA_SIG_KEY = 'metadata_sig' |
| METADATA_SIZE_KEY = 'metadata_size' |
| VERSION_KEY = 'version' |
| SRC_VERSION_KEY = 'source_ver' |
| MD5_HASH_KEY = 'hash_md5' |
| SHA1_HASH_KEY = 'hash_sha1' |
| SHA256_HASH_KEY = 'hash_sha256' |
| |
| def __init__(self, app_data): |
| """Initialize AppData |
| |
| Args: |
| app_data: Dictionary containing attributes used to init AppData instance. |
| |
| Attributes: |
| template: Defines the format of an app element in the XML response. |
| appid: appid of the requested app. |
| name: Filename of requested app on the mock Lorry server. |
| is_delta: True iff the payload is a delta update. |
| size: Size of the payload. |
| md5_hash: md5 hash of the payload. |
| metadata_sig: Metadata signature. |
| metadata_size: Metadata size. |
| sha1_hash: SHA1 hash of the payload. |
| sha256_hash: SHA256 hash of the payload. |
| version: ChromeOS version the payload is tied to. |
| src_version: Source version for delta updates. |
| """ |
| self.appid = app_data[self.APPID_KEY] |
| self.name = app_data[self.NAME_KEY] |
| self.version = app_data[self.VERSION_KEY] |
| self.is_delta = app_data[self.IS_DELTA_KEY] |
| self.src_version = ( |
| app_data[self.SRC_VERSION_KEY] if self.is_delta else None) |
| self.size = app_data[self.SIZE_KEY] |
| self.metadata_sig = app_data[self.METADATA_SIG_KEY] |
| self.metadata_size = app_data[self.METADATA_SIZE_KEY] |
| self.md5_hash = app_data[self.MD5_HASH_KEY] |
| self.sha1_hash = app_data[self.SHA1_HASH_KEY] |
| self.sha256_hash = app_data[self.SHA256_HASH_KEY] |
| self.url = None # Determined per-request |
| |
| def __str__(self): |
| if self.is_delta: |
| return "{} v{}: delta update from base v{}".format( |
| self.appid, self.version, self.src_version) |
| return "{} v{}: full update/install".format( |
| self.appid, self.version) |
| |
| def MatchRequest(self, request): |
| """Returns true iff the app matches a given client request. |
| |
| An app matches a request if the appid matches the requested appid. |
| Additionally, if the app describes a delta update payload, the request |
| must be able to accept delta payloads, and the source versions must match. |
| If the request is not an update, the versions must match. |
| |
| Args: |
| request: A request object describing a client request. |
| |
| Returns: |
| True if the app matches the given request, False otherwise. |
| """ |
| |
| # TODO(chowes): We only account for tip/branch versions. We need to be able |
| # to handle full version strings as well as developer builds that don't have |
| # a "real" final version component. |
| |
| if self.appid != request.appid: |
| return False |
| |
| try: |
| if request.request_type == request.RequestType.UPDATE: |
| if self.is_delta: |
| if not request.delta_okay: |
| return False |
| if VersionCmp(request.version, self.src_version) != 0: |
| return False |
| return VersionCmp(request.version, self.version) < 0 |
| |
| if request.request_type == request.RequestType.INSTALL: |
| if self.is_delta: |
| return False |
| return VersionCmp(request.version, self.version) == 0 |
| |
| else: |
| return False |
| |
| except ValueError as err: |
| logging.error("Unable to compare version strings (%s)", str(err)) |
| return False |
| |
| |
| class AppIndex(object): |
| """An index of available app payload information. |
| |
| Index of available apps used to generate responses to Omaha requests. The |
| index consists of lists of payload information associated with a given appid, |
| since we can have multiple payloads for a given app (different versions, |
| delta/full payloads). The index is built by scanning a given directory for |
| json files that describe the available payloads. |
| """ |
| |
| def __init__(self, directory): |
| """Initializes an AppIndex instance. |
| |
| Attributes: |
| directory: Directory containing metdata and payloads, can be None. |
| index: Dictionary of metadata describing payloads for a given appid. |
| """ |
| self._directory = directory |
| self._index = {} |
| |
| def Scan(self): |
| """Invalidates the current cache and scans the directory. |
| |
| Clears the cached index and rescans the directory. |
| """ |
| self._index.clear() |
| |
| if self._directory is None: |
| return |
| |
| for f in os.listdir(self._directory): |
| if f.endswith('.json'): |
| try: |
| with open(os.path.join(self._directory, f), 'r') as metafile: |
| metadata_str = metafile.read() |
| metadata = json.loads(metadata_str) |
| app = AppData(metadata) |
| |
| if app.appid not in self._index: |
| self._index[app.appid] = [] |
| self._index[app.appid].append(app) |
| except (IOError, KeyError, ValueError) as err: |
| logging.error("Failed to read app data from %s (%s)", f, str(err)) |
| raise |
| logging.debug("Found app data: %s", str(app)) |
| |
| def Find(self, request): |
| """Search the index for a given appid. |
| |
| Searches the index for the payloads matching a client request. Matching is |
| based on appid, version, and whether the client is searching for an update |
| and can handle delta payloads. |
| |
| Args: |
| request: AppRequest describing the client request |
| |
| Returns: |
| An AppData object describing an available payload matching the client |
| request, or None if no matches are found. Prefer delta payloads if the |
| client can accept them and if one is available. |
| """ |
| # Find a list of payloads matching the client request |
| matches = [app for app in self._index.get(request.appid, []) if |
| app.MatchRequest(request)] |
| |
| if not matches: |
| return None |
| |
| # Find the highest version out of the matching payloads |
| max_version = reduce( |
| lambda a, b: a if VersionCmp(a.version, b.version) > 0 |
| else b, matches).version |
| |
| matches = [app for app in matches if app.version == max_version] |
| |
| # If the client can handle a delta, prefer to send a delta |
| if request.delta_okay: |
| match = next((x for x in matches if x.is_delta), None) |
| match = match if match else next(iter(matches), None) |
| else: |
| match = next(iter(matches), None) |
| |
| # Since we set the URL per-request, we have to return a copy |
| return copy.copy(match) |
| |
| def Contains(self, request): |
| """Checks if the AppIndex contains any apps matching a given request appid. |
| |
| Checks the index for an appid matching the appid in the given request. This |
| is necessary because it allows us to differentiate between the case where we |
| have no new versions of an app and the case where we have no information |
| about an app at all. |
| |
| Args: |
| request: Describes the client request |
| |
| Returns: |
| True if the index contains any appids matching the appid given in the |
| request. |
| """ |
| return request.appid in self._index |
| |
| |
| class NebraskaHandler(BaseHTTPRequestHandler): |
| """HTTP request handler for Omaha requests.""" |
| |
| def GetRequestString(self): |
| """Extracts the request string from an HTML POST request""" |
| request_len = int(self.headers.getheader('content-length')) |
| return self.rfile.read(request_len) |
| |
| def do_POST(self): |
| """Responds to XML-formatted Omaha requests.""" |
| request_str = self.GetRequestString() |
| logging.debug("Received request: %s", request_str) |
| |
| request = Request(request_str) |
| try: |
| matches = [] |
| for app in request.ParseRequest(): |
| logging.debug("Received request: %s", str(app)) |
| |
| if app.request_type == app.RequestType.INSTALL: |
| match = self.server.owner.source_index.Find(app) |
| elif app.request_type == app.RequestType.UPDATE: |
| match = self.server.owner.target_index.Find(app) |
| else: |
| continue |
| |
| if match: |
| matches.append(match) |
| logging.debug("Found matching payload: %s", str(match)) |
| elif self.server.owner.target_index.Contains(app): |
| logging.debug("No updates available for %s", app.appid) |
| else: |
| logging.debug("No matching payloads") |
| |
| except ValueError: |
| self.send_error(400, "Invalid update or install request") |
| return |
| |
| self.send_error(500, "Not implemented!") |
| |
| |
| class NebraskaServer(object): |
| """A simple Omaha server instance. |
| |
| A simple mock of an Omaha server. Responds to XML-formatted update/install |
| requests based on the contents of metadata files in target and source |
| directories, respectively. These metadata files are used to configure |
| responses to Omaha requests from Update Engine and describe update and install |
| payloads provided by another server. |
| """ |
| |
| def __init__(self, target_dir, source_dir=None, port=0): |
| """Initializes a server instance. |
| |
| Args: |
| target_dir: Directory to index for information about target payloads. |
| source_dir: Directory to index for information about source payloads. |
| port: Port the server should run on, 0 if the OS should assign a port. |
| |
| Attributes: |
| target_index: Index of metadata files in the target directory. |
| source_index: Index of metadata files in the source directory. |
| """ |
| self._port = port |
| self._httpd = None |
| self._server_thread = None |
| self.target_index = AppIndex(target_dir) |
| self.source_index = AppIndex(source_dir) |
| |
| def Start(self): |
| """Starts a mock Omaha HTTP server.""" |
| self.target_index.Scan() |
| self.source_index.Scan() |
| |
| self._httpd = HTTPServer(('', self.Port()), NebraskaHandler) |
| self._port = self._httpd.server_port |
| self._httpd.owner = self |
| self._server_thread = threading.Thread(target=self._httpd.serve_forever) |
| self._server_thread.start() |
| |
| def Stop(self): |
| """Stops the mock Omaha server.""" |
| self._httpd.shutdown() |
| self._server_thread.join() |
| |
| def Port(self): |
| """Returns the server's port.""" |
| return self._port |
| |
| |
| def ParseArguments(argv): |
| """Parses command line arguments. |
| |
| Args: |
| argv: List of commandline arguments |
| |
| Returns: |
| Namespace object containing parsed arguments |
| """ |
| parser = argparse.ArgumentParser(description=__doc__) |
| |
| required_args = parser.add_argument_group('Required Arguments') |
| required_args.add_argument('--target-dir', metavar='DIR', help='Directory ' |
| 'containing payloads for updates.', required=True) |
| |
| optional_args = parser.add_argument_group('Optional Arguments') |
| optional_args.add_argument('--source-dir', metavar='DIR', default=None, |
| help='Directory containing payloads for ' |
| 'installation.', required=False) |
| optional_args.add_argument('--port', metavar='PORT', type=int, default=0, |
| help='Port to run the server on') |
| |
| return parser.parse_args(argv[1:]) |
| |
| |
| def main(argv): |
| logging.basicConfig(level=logging.DEBUG) |
| opts = ParseArguments(argv) |
| |
| nebraska = NebraskaServer(source_dir=opts.source_dir, |
| target_dir=opts.target_dir, |
| port=opts.port) |
| |
| nebraska.Start() |
| logging.info("Running on port %d. Press 'q' to quit.", nebraska.Port()) |
| |
| try: |
| while raw_input() != 'q': |
| pass |
| except(EOFError, KeyboardInterrupt, SystemExit): |
| pass |
| |
| logging.info("Exiting...") |
| nebraska.Stop() |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main(sys.argv)) |