| #!/usr/bin/env python3 |
| # -*- 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 base64 |
| import copy |
| import datetime |
| import errno |
| import http |
| import json |
| import logging |
| import os |
| import pprint |
| import shutil |
| import signal |
| import sys |
| import threading |
| import traceback |
| import urllib |
| |
| from http import server |
| from xml.dom import minidom |
| from xml.etree import ElementTree |
| |
| |
| # '5' and '7' are just default values for testing. |
| _FIRMWARE_VER = '5' |
| _KERNEL_VER = '7' |
| |
| # This is the same for all images on canary channel. |
| _CANARY_APP_ID = '{90F229CE-83E2-4FAF-8479-E368A34938B1}' |
| |
| |
| class Error(Exception): |
| """The base class for failures raised by Nebraska.""" |
| |
| |
| class InvalidRequestError(Error): |
| """Raised for invalid requests.""" |
| |
| |
| class Request(object): |
| """Request consisting of a list of apps to update/install.""" |
| |
| APP_TAG = 'app' |
| APP_APPID_ATTR = 'appid' |
| APP_DELTA_OKAY_ATTR = 'delta_okay' |
| # The following app attributes should be the same for all incoming apps if |
| # they exist. 'version' should be repeated in all apps, but other attributes |
| # can be omited in non-platform apps. Or at least they should be present in |
| # one of the apps. For this reason we keep these values in the Request |
| # object itself and not the AppRequest (except for 'version'). |
| APP_VERSION_ATTR = 'version' |
| APP_CHANNEL_ATTR = 'track' |
| APP_BOARD_ATTR = 'board' |
| |
| UPDATE_CHECK_TAG = 'updatecheck' |
| ROLLBACK_ALLOWED_ATTR = 'rollback_allowed' |
| |
| PING_TAG = 'ping' |
| |
| EVENT_TAG = 'event' |
| EVENT_TYPE_ATTR = 'eventtype' |
| EVENT_RESULT_ATTR = 'eventresult' |
| EVENT_PREVIOUS_VERSION_ATTR = 'previousversion' |
| |
| # Update events and result codes. |
| EVENT_TYPE_UNKNOWN = 0 |
| EVENT_TYPE_DOWNLOAD_COMPLETE = 1 |
| EVENT_TYPE_INSTALL_COMPLETE = 2 |
| EVENT_TYPE_UPDATE_COMPLETE = 3 |
| EVENT_TYPE_UPDATE_DOWNLOAD_STARTED = 13 |
| EVENT_TYPE_UPDATE_DOWNLOAD_FINISHED = 14 |
| |
| EVENT_RESULT_ERROR = 0 |
| EVENT_RESULT_SUCCESS = 1 |
| EVENT_RESULT_SUCCESS_REBOOT = 2 |
| EVENT_RESULT_UPDATE_DEFERRED = 9 |
| |
| # update_engine sends this version for all non-platform Apps when the |
| # operation is install (or event for an install). |
| _VERSION_ZERO = '0.0.0.0' |
| |
| class RequestType(object): |
| """Simple enumeration for encoding request type.""" |
| INSTALL = 1 # Request installation of a new app. |
| UPDATE = 2 # Request update for an existing app. |
| EVENT = 3 # Just an event request. |
| |
| def __init__(self, request_str): |
| """Initializes a request instance. |
| |
| Args: |
| request_str: XML-formatted request string. |
| """ |
| self.request_str = request_str |
| logging.debug('Received request: %s', self.request_str) |
| |
| self.version = None |
| self.track = None |
| self.board = None |
| self.request_type = None |
| |
| self.app_requests = [] |
| |
| self.ParseRequest() |
| |
| 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: |
| InvalidRequestError if the request string is not a valid XML request. |
| """ |
| try: |
| request_root = ElementTree.fromstring(self.request_str) |
| except ElementTree.ParseError as err: |
| raise InvalidRequestError( |
| 'Request string is not valid XML: %s' % err) |
| |
| # TODO(http://crbug.com/914936): It would be better to specifically check |
| # the platform app. An install is signalled by omitting the update_check for |
| # the platform app, so we assume that if we have one appid for which the |
| # update_check tag is omitted, it is the platform app and this is an install |
| # request. This assumption should be fine since we never mix updates with |
| # requests that do not include an update_check tag. |
| app_elements = request_root.findall(self.APP_TAG) |
| update_check_count = len( |
| [x for x in app_elements if x.find(self.UPDATE_CHECK_TAG) is not None]) |
| if update_check_count == 0: |
| self.request_type = Request.RequestType.EVENT |
| elif update_check_count == len(app_elements) - 1: |
| self.request_type = Request.RequestType.INSTALL |
| elif update_check_count == len(app_elements): |
| self.request_type = Request.RequestType.UPDATE |
| else: |
| raise InvalidRequestError( |
| 'Client request omits update_check tag for more than one, but not all' |
| ' app requests.') |
| |
| for app in app_elements: |
| app_request = Request.AppRequest(app, self.request_type) |
| self.app_requests.append(app_request) |
| |
| def _CheckAttributesAndReturnIt(attribute, in_all=False, ignore_value=None): |
| """Checks the attribute integrity among all apps and return its value. |
| |
| The most likely scenario is that the value of the attribute is the same |
| for all apps if existed. It can optionally be in one or more apps, but |
| they are all equal. |
| |
| Args: |
| attribute: An attribute of the app tag. |
| in_all: If true, the attribute should exist among all apps. |
| ignore_value: The attribute value that we want to omit from the list of |
| attributes. |
| |
| Returns: |
| The value of the attribute. If no valid attribute value is found, |
| ignore_value will be returned. |
| """ |
| all_attrs = [getattr(x, attribute) for x in self.app_requests] |
| if in_all and (ignore_value in all_attrs): |
| raise InvalidRequestError( |
| 'All apps should have "%s" attribute.' % attribute) |
| |
| # Filter out non-ignore_value elements into a set. |
| unique_attrs = set(x for x in all_attrs if x != ignore_value) |
| if not unique_attrs: |
| # If no app had the attribute, we can just return the invalid one as it |
| # was the only one. |
| return ignore_value |
| |
| if len(unique_attrs) > 1: |
| raise InvalidRequestError( |
| 'Attribute "%s" is not the same in all app tags.' % attribute) |
| return unique_attrs.pop() |
| |
| if self.request_type == Request.RequestType.UPDATE: |
| # Update requests should have the same version for all Apps. |
| self.version = _CheckAttributesAndReturnIt(self.APP_VERSION_ATTR, |
| in_all=True) |
| else: |
| # Install requests should have non-zero version for the platform App and |
| # zero for all others. Event requests can be either for install or update |
| # so they can have different combinations of versions. |
| self.version = _CheckAttributesAndReturnIt( |
| self.APP_VERSION_ATTR, ignore_value=self._VERSION_ZERO) |
| |
| self.track = _CheckAttributesAndReturnIt(self.APP_CHANNEL_ATTR) |
| self.board = _CheckAttributesAndReturnIt(self.APP_BOARD_ATTR) |
| if self.track is None or self.board is None: |
| raise InvalidRequestError('Either track(%s) or board(%s) attributes are ' |
| 'empty in all apps.' % (self.track, self.board)) |
| |
| 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. |
| """ |
| |
| def __init__(self, app, request_type): |
| """Initializes a Request. |
| |
| Args: |
| app: The request app XML element. |
| request_type: install, update, or event. |
| |
| More on event pings: |
| https://github.com/google/omaha/blob/master/doc/ServerProtocolV3.md |
| """ |
| self.request_type = request_type |
| self.appid = None |
| self.version = None |
| self.track = None |
| self.board = None |
| self.ping = None |
| self.delta_okay = None |
| self.event_type = None |
| self.event_result = None |
| self.previous_version = None |
| self.rollback_allowed = None |
| self.has_update_check = False |
| |
| self.ParseApp(app) |
| |
| def __str__(self): |
| """Returns a string representation of an AppRequest.""" |
| if self.request_type == Request.RequestType.EVENT: |
| return str(self.appid) |
| elif self.request_type == Request.RequestType.INSTALL: |
| return 'install %s v%s' % (self.appid, self.version) |
| elif self.request_type == Request.RequestType.UPDATE: |
| return '%s update %s from v%s' % ( |
| 'delta' if self.delta_okay else 'full', self.appid, self.version) |
| |
| def ParseApp(self, app): |
| """Parses the app XML element and populates the self object. |
| |
| Args: |
| app: The request app XML element. |
| |
| Raises InvalidRequestError if the input request string is in |
| invalid format. |
| """ |
| self.appid = app.get(Request.APP_APPID_ATTR) |
| self.version = app.get(Request.APP_VERSION_ATTR) |
| self.track = app.get(Request.APP_CHANNEL_ATTR) |
| self.board = app.get(Request.APP_BOARD_ATTR) |
| self.delta_okay = app.get(Request.APP_DELTA_OKAY_ATTR) == 'true' |
| |
| update_check = app.find(Request.UPDATE_CHECK_TAG) |
| self.has_update_check = update_check is not None |
| self.rollback_allowed = ( |
| self.has_update_check and |
| update_check.get(Request.ROLLBACK_ALLOWED_ATTR) == 'true') |
| |
| event = app.find(Request.EVENT_TAG) |
| if event is not None: |
| self.event_type = int(event.get(Request.EVENT_TYPE_ATTR)) |
| self.event_result = int(event.get(Request.EVENT_RESULT_ATTR, 0)) |
| self.previous_version = event.get(Request.EVENT_PREVIOUS_VERSION_ATTR) |
| |
| self.ping = app.find(Request.PING_TAG) is not None |
| |
| if None in (self.request_type, self.appid, self.version): |
| raise InvalidRequestError('Invalid app request.') |
| |
| def MatchAppData(self, app_data, partial_match_appid=False, |
| check_against_canary=False, ignore_appid=False): |
| """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. |
| |
| Args: |
| app_data: An AppData object describing a valid app data. |
| partial_match_appid: If true, it will partially check the app_data's |
| appid. Which means that if app_data's appid is a substring of |
| request's appid, it will be a match. |
| check_against_canary: If the DUT was on a canary channel, the App ID |
| update_engine provides is from the canary channel, which is |
| different from the release App ID that we use for generating payload |
| properties file. But the good news is that there is only one canary |
| App ID for all devices. Turning this flag on, checks the incoming |
| request against the presumed canary App ID. |
| ignore_appid: If true, don't check the App ID and assume a match. |
| |
| Returns: |
| True if the request matches the given app, False otherwise. |
| """ |
| if not ignore_appid and self.appid != app_data.appid: |
| if partial_match_appid: |
| if app_data.appid not in self.appid: |
| return False |
| elif check_against_canary: |
| if app_data.canary_appid != self.appid: |
| return False |
| else: |
| return False |
| |
| # At this point, there was a match. |
| if self.request_type == Request.RequestType.UPDATE: |
| if app_data.is_delta: |
| return self.delta_okay |
| else: |
| return True |
| |
| if self.request_type == Request.RequestType.INSTALL: |
| return not app_data.is_delta |
| |
| return False |
| |
| |
| class Response(object): |
| """An update/install response. |
| |
| A response to an update or install request consists of an XML-encoded list |
| of responses for each appid in the client request. This class takes a list of |
| responses for update/install requests and compiles them into a single element |
| constituting an aggregate response that can be returned to the client in XML |
| format based on the format of an XML response template. |
| """ |
| |
| def __init__(self, request, config): |
| """Initialize a reponse from a list of matching apps. |
| |
| Args: |
| request: Request instance describing client requests. |
| config: An instance of Config class. |
| """ |
| self._request = request |
| self._config = config |
| |
| curr = datetime.datetime.now() |
| # Jan 1 2007 is the start of Omaha v3 epoch: |
| # https://github.com/google/omaha/blob/master/doc/ServerProtocolV3.md#attributes-12 |
| self._elapsed_days = (curr - datetime.datetime(2007, 1, 1)).days |
| self._elapsed_seconds = int(( |
| curr - datetime.datetime.combine(curr.date(), |
| datetime.time.min)).total_seconds()) |
| |
| if self._request.request_type in (Request.RequestType.INSTALL, |
| Request.RequestType.UPDATE): |
| if self._config.return_noupdate_starting > 1: |
| self._config.return_noupdate_starting -= 1 |
| elif self._config.return_noupdate_starting == 1: |
| self._config.no_update = True |
| |
| def GetXMLString(self): |
| """Generates a response to a set of client requests. |
| |
| Given a client request consisting of one or more app requests, generate a |
| response to each of these requests and combine them into a single |
| XML-formatted response. |
| |
| Returns: |
| XML-formatted response string consisting of a response to each app request |
| in the incoming request from the client. |
| """ |
| try: |
| response_xml = ElementTree.Element( |
| 'response', attrib={'protocol': '3.0', 'server': 'nebraska'}) |
| ElementTree.SubElement( |
| response_xml, 'daystart', |
| attrib={'elapsed_days': str(self._elapsed_days), |
| 'elapsed_seconds': str(self._elapsed_seconds)}) |
| |
| # The list of app data that we have already matched. This is populated |
| # during the for loop below. |
| matched_apps = set() |
| for app_request in self._request.app_requests: |
| response_xml.append( |
| self.AppResponse(app_request, self._config, matched_apps).Compile()) |
| |
| except Exception as err: |
| logging.error(traceback.format_exc()) |
| raise Error('Failed to compile response: %s' % err) |
| |
| return ElementTree.tostring( |
| response_xml, encoding='UTF-8', method='xml') |
| |
| class AppResponse(object): |
| """Response to an app request. |
| |
| If the request was an update or install request, the response should include |
| a matching app if one was found. Addionally, the response should include |
| responses to pings and events as appropriate. |
| """ |
| |
| def __init__(self, app_request, config, matched_apps): |
| """Initialize an AppResponse. |
| |
| Args: |
| app_request: AppRequest representing a client request. |
| config: An instance of Config class. |
| matched_apps: The set of app data that have been matched already. |
| """ |
| self._app_request = app_request |
| self._config = config |
| self._app_data = None |
| self._payloads_address = None |
| |
| # If no update was requested, don't process anything anymore. |
| if self._config.no_update: |
| return |
| |
| if self._app_request.request_type == Request.RequestType.INSTALL: |
| # Platform requests for install should not have any payload associated |
| # with them. |
| if self._app_request.has_update_check: |
| self._app_data = self._config.install_app_index.Find( |
| self._app_request, matched_apps, True) |
| self._payloads_address = self._config.install_payloads_address |
| |
| elif self._app_request.request_type == Request.RequestType.UPDATE: |
| self._app_data = self._config.update_app_index.Find( |
| self._app_request, matched_apps, self._config.full_payload, |
| self._config.ignore_appid) |
| self._payloads_address = self._config.update_payloads_address |
| |
| if self._app_data: |
| logging.debug('Found matching payload: %s', str(self._app_data)) |
| elif self._app_request.request_type == Request.RequestType.UPDATE: |
| logging.debug('No matching updates payload available for App ID %s', |
| self._app_request.appid) |
| elif (self._app_request.has_update_check and |
| self._app_request.request_type == Request.RequestType.INSTALL): |
| logging.debug('No matching install payload available for App ID %s', |
| self._app_request.appid) |
| |
| def Compile(self): |
| """Compiles an app description into XML format. |
| |
| Compile the app description into an ElementTree Element that can be used |
| to compile a response to a client request, and ultimately converted into |
| XML. |
| |
| Returns: |
| An ElementTree Element instance describing an update or install payload. |
| """ |
| app_response = ElementTree.Element( |
| 'app', attrib={'appid': self._app_request.appid, 'status': 'ok'}) |
| |
| if self._app_request.ping: |
| ElementTree.SubElement(app_response, 'ping', attrib={'status': 'ok'}) |
| if self._app_request.event_type is not None: |
| ElementTree.SubElement(app_response, 'event', attrib={'status': 'ok'}) |
| |
| if self._app_data is not None: |
| update_check_attribs = {'status': 'ok'} |
| if (self._config.is_rollback and |
| self._app_request.rollback_allowed): |
| update_check_attribs['_is_rollback'] = 'true' |
| # Techincally we have to always send _firmware_version and |
| # _kernel_version attributes regardless of the rollback situation. But |
| # for the sake of simplicity, we can just send it when rollback was |
| # requested. |
| index_strs = ['', '_0', '_1', '_2', '_3', '_4'] |
| for idx in index_strs: |
| update_check_attribs['_firmware_version' + idx] = _FIRMWARE_VER |
| update_check_attribs['_kernel_version' + idx] = _KERNEL_VER |
| if self._config.eol_date is not None: |
| update_check_attribs['_eol_date'] = str(self._config.eol_date) |
| update_check = ElementTree.SubElement( |
| app_response, 'updatecheck', attrib=update_check_attribs) |
| urls = ElementTree.SubElement(update_check, 'urls') |
| for _ in range(self._config.num_urls): |
| ElementTree.SubElement( |
| urls, 'url', attrib={'codebase': self._payloads_address}) |
| manifest = ElementTree.SubElement( |
| update_check, 'manifest', |
| attrib={'version': self._app_data.target_version}) |
| actions = ElementTree.SubElement(manifest, 'actions') |
| ElementTree.SubElement( |
| actions, 'action', |
| attrib={'event': 'update', 'run': self._app_data.name}) |
| action = ElementTree.SubElement( |
| actions, 'action', |
| attrib={'ChromeOSVersion': self._app_data.target_version, |
| 'ChromeVersion': '1.0.0.0', |
| 'DisablePayloadBackoff': str( |
| self._config.disable_payload_backoff).lower(), |
| 'IsDeltaPayload': str(self._app_data.is_delta).lower(), |
| 'MaxDaysToScatter': '14', |
| 'MetadataSignatureRsa': self._app_data.metadata_signature, |
| 'MetadataSize': str(self._app_data.metadata_size), |
| 'sha256': self._app_data.sha256, |
| 'event': 'postinstall'}) |
| if self._config.failures_per_url is not None: |
| action.set('MaxFailureCountPerUrl', |
| str(self._config.failures_per_url)) |
| if self._config.critical_update: |
| action.set('deadline', 'now') |
| if self._app_data.public_key is not None: |
| action.set('PublicKeyRsa', self._app_data.public_key) |
| packages = ElementTree.SubElement(manifest, 'packages') |
| ElementTree.SubElement( |
| packages, 'package', |
| attrib={'fp': '1.%s' % self._app_data.sha256_hex, |
| 'hash_sha256': self._app_data.sha256_hex, |
| 'name': self._app_data.name, |
| 'required': 'true', |
| 'size': str(self._app_data.size)}) |
| |
| # For installs, if there was no updatecheck, there will be no updatecheck |
| # response. Just a no update in the app tag's status attribute. |
| elif (self._app_request.request_type == Request.RequestType.INSTALL and |
| not self._app_request.has_update_check): |
| app_response.attrib['status'] = 'noupdate' |
| elif self._app_request.request_type == Request.RequestType.UPDATE: |
| update_check_attribs = {'status': 'noupdate'} |
| if self._config.eol_date is not None: |
| update_check_attribs['_eol_date'] = str(self._config.eol_date) |
| ElementTree.SubElement(app_response, 'updatecheck', |
| attrib=update_check_attribs) |
| |
| return app_response |
| |
| |
| 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 (delta/full payloads). The |
| index is built by scanning a given directory for json files that describe the |
| available payloads. |
| |
| Attributes: |
| _directory: Directory containing metdata and payloads, can be None. |
| _index: A list of AppData describing payloads. |
| """ |
| |
| def __init__(self, directory): |
| """Initializes an AppIndex instance.""" |
| self._directory = directory |
| self._index = [] |
| |
| self._Scan() |
| |
| def _Scan(self): |
| """Scans the directory and loads all available properties files.""" |
| 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) |
| # Get the name from file name itself, assuming the metadata file |
| # ends with '.json'. |
| metadata[AppIndex.AppData.NAME_KEY] = f[:-len('.json')] |
| app = AppIndex.AppData(metadata) |
| self._index.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, matched_apps, full_payload, ignore_appid=False): |
| """Search the index for a given appid. |
| |
| Searches the index for the payloads matching a client request. Matching is |
| based on appid, and whether the client is searching for an update and can |
| handle delta payloads. |
| |
| Args: |
| request: AppRequest describing the client request. |
| matched_apps: The set of app data that have been matched already. |
| full_payload: True if we want full payload, False if delta payload, None |
| if we don't care. |
| ignore_appid: True to ignore the request's App ID and use the first |
| available app. |
| |
| 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 exactly matching the client request. |
| matches = [app_data for app_data in self._index if |
| request.MatchAppData(app_data)] |
| |
| # Check to see if the incoming requests where from a canary channel (mostly |
| # a test image). |
| if not matches: |
| matches = [app_data for app_data in self._index if |
| request.MatchAppData(app_data, check_against_canary=True)] |
| |
| if not matches: |
| # Look to see if there is any AppData with empty or partial App ID. Then |
| # return the first one you find. This basically will work as a wild card |
| # to allow AppDatas that don't have an AppID or their AppID is incomplete |
| # (e.g. empty platform App ID + _ + DLC App ID) to work just fine. |
| # |
| # The reason we just don't do this in one pass is that we want to find all |
| # the matches with exact appid and iif there was no match, we do the appid |
| # partial match. |
| matches = [app_data for app_data in self._index if |
| request.MatchAppData(app_data, partial_match_appid=True)] |
| |
| if not matches and ignore_appid: |
| matches = [app_data for app_data in self._index if |
| request.MatchAppData(app_data, ignore_appid=True)] |
| |
| # Now remove App ID matches that have already been matched by other |
| # requests. |
| matches = [app_data for app_data in matches |
| if app_data not in matched_apps] |
| |
| if full_payload is False and not request.delta_okay: |
| logging.error('The update client indicated that it can not accept a delta' |
| ' payload, but the Nebraska is instructed to only serve' |
| ' delta payload. This is a bug.') |
| return None |
| |
| full_match = next((x for x in matches if not x.is_delta), None) |
| delta_match = next((x for x in matches if x.is_delta), None) |
| |
| if full_payload or not request.delta_okay: |
| match = full_match |
| else: |
| match = (delta_match if full_payload is False or delta_match |
| else full_match) |
| |
| if not match: |
| return None |
| |
| # Add this App data to the list of already matched ones. |
| matched_apps.add(match) |
| |
| return copy.copy(match) |
| |
| 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. |
| |
| Attributes: |
| appid: App ID of the requested app. |
| canary_appid: canary version App ID 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. |
| metadata_signature: Metadata signature. |
| metadata_size: Metadata size. |
| sha256_hex: SHA256 hash of the payload encoded in hexadecimal. |
| sha256: SHA256 hash of the payload encoded in base64 format. |
| target_version: ChromeOS version the payload is tied to. |
| source_version: Source version for delta updates. |
| public_key: The public key for signature verification. It should be in |
| base64 format. |
| """ |
| APPID_KEY = 'appid' |
| NAME_KEY = 'name' |
| IS_DELTA_KEY = 'is_delta' |
| SIZE_KEY = 'size' |
| METADATA_SIG_KEY = 'metadata_signature' |
| METADATA_SIZE_KEY = 'metadata_size' |
| TARGET_VERSION_KEY = 'target_version' |
| SOURCE_VERSION_KEY = 'source_version' |
| SHA256_HEX_KEY = 'sha256_hex' |
| PUBLIC_KEY_RSA_KEY = 'public_key' |
| |
| def __init__(self, app_data): |
| """Initialize AppData. |
| |
| Args: |
| app_data: Dictionary containing attributes used to initialize AppData |
| instance. |
| """ |
| self.appid = app_data[self.APPID_KEY] |
| # Replace the begining of the App ID with the canary version. |
| self.canary_appid = '' |
| if len(self.appid) >= len(_CANARY_APP_ID): |
| self.canary_appid = (_CANARY_APP_ID + |
| self.appid[len(_CANARY_APP_ID):]) |
| self.name = app_data[self.NAME_KEY] |
| self.target_version = app_data[self.TARGET_VERSION_KEY] |
| self.is_delta = app_data[self.IS_DELTA_KEY] |
| self.source_version = ( |
| app_data[self.SOURCE_VERSION_KEY] if self.is_delta else None) |
| self.size = app_data[self.SIZE_KEY] |
| # Sometimes the payload is not signed, hence the matadata signature is |
| # null, but we should pass empty string instead of letting the value be |
| # null (the XML element tree will break). |
| self.metadata_signature = app_data[self.METADATA_SIG_KEY] or '' |
| self.metadata_size = app_data[self.METADATA_SIZE_KEY] |
| self.public_key = app_data.get(self.PUBLIC_KEY_RSA_KEY) |
| # Unfortunately the sha256_hex that paygen generates is actually a base64 |
| # sha256 hash of the payload for some unknown historical reason. But the |
| # Omaha response contains the hex value of that hash. So here convert the |
| # value from base64 to hex so nebraska can send the correct version to the |
| # client. See b/131762584. |
| self.sha256 = app_data[self.SHA256_HEX_KEY] |
| self.sha256_hex = base64.b16encode( |
| base64.b64decode(self.sha256)).decode('utf-8') |
| self.url = None # Determined per-request. |
| |
| def __str__(self): |
| if self.is_delta: |
| return '%s v%s: delta update from base v%s' % ( |
| self.appid, self.target_version, self.source_version) |
| return '%s v%s: full update/install' % ( |
| self.appid, self.target_version) |
| |
| |
| class Config(object): |
| """The configs that can change the behavior/value of responses. |
| |
| If a new feature needs to be added to nebraska that needs to be controlled |
| from the test, a attribute should be added here with a proper default value |
| with corresponding logic based on that value to the rest of nebraska's |
| code. Then these values can be changed using the `update_config` API of the |
| nebraska. |
| """ |
| |
| def __init__(self): |
| """Initliazes the response properties.""" |
| |
| # The base address for update payload URLs. |
| self.update_payloads_address = None |
| |
| # The base address for install payload URLs. |
| self.install_payloads_address = None |
| |
| # An instance of AppIndex class to be used for update requests. |
| self.update_app_index = None |
| |
| # An instance of AppIndex class to be used for install requests. |
| self.install_app_index = None |
| |
| # If true, the response will include 'deadline=now' which indicates the |
| # update is critical. |
| self.critical_update = False |
| |
| # If true, it will return a noupdate response regardless. |
| self.no_update = False |
| |
| # Whether the update request will be a rollback or not. |
| self.is_rollback = False |
| |
| # How many times each url can fail. |
| self.failures_per_url = None |
| |
| # Instruct update_engine to disable the back-off logic on the client |
| # altogether. |
| self.disable_payload_backoff = False |
| |
| # Number of URLs that should be returned in the response. |
| self.num_urls = 1 |
| |
| # The number of days from unix epoch which device goes end of life. |
| self.eol_date = None |
| |
| # Indicates whether we want a full payload or not. None means we don't |
| # care. Can be set a boolean value. |
| self.full_payload = None |
| |
| # Ignore the App ID field of incoming requests and use whichever app is |
| # available. |
| self.ignore_appid = False |
| |
| # When set to 0 (the default value), disables returning noupdate (it will |
| # return an update if there is any). If set to a positive integer N, returns |
| # noupdate for the Nth check and for every check thereafter. For example, |
| # if set to 1, returns noupdate starting from the first check, i.e., always |
| # returns noupdate. |
| self.return_noupdate_starting = 0 |
| |
| def Update(self, **kwargs): |
| """Updates the attributes of this class. |
| |
| Args: |
| kwargs: A dictionary of args that will update the default attributes of |
| this class. |
| """ |
| for key, value in kwargs.items(): |
| if not hasattr(self, key): |
| logging.error('Invalid config attributed %s is passed.', key) |
| continue |
| setattr(self, key, value) |
| |
| logging.debug('Config updated to:\n%s', pprint.pformat(self.__dict__)) |
| |
| |
| class Nebraska(object): |
| """An instance of this class allows responding to incoming Omaha requests. |
| |
| This class has the responsibility to manufacture Omaha responses based on |
| the input update requests. This should be the main point of use of the |
| Nebraska. If any changes to the behavior of Nebraska is intended, like |
| creating critical update responses, or messing up with firmware and kernel |
| versions, new flags should be added here to add that feature. |
| """ |
| def __init__(self): |
| """Initializes the Nebraska instance.""" |
| self._config = Config() |
| |
| def GetResponseToRequest(self, request): |
| """Returns the response corresponding to a request. |
| |
| Args: |
| request: The Request object representation of the incoming request. |
| |
| Returns: |
| The string representation of the created response. |
| """ |
| response = Response(request, self._config).GetXMLString() |
| # Make the XML response look pretty. |
| response_str = minidom.parseString(response).toprettyxml(indent=' ', |
| encoding='UTF-8') |
| logging.debug('Sent response: %s', response_str) |
| return response_str |
| |
| def UpdateConfig(self, **kwargs): |
| """Updates the config. |
| |
| Args: |
| kwargs: Look at Config.Update(). |
| """ |
| self._config.Update(**kwargs) |
| |
| |
| def QueryDictToDict(query): |
| """Converts the query string generated dict to a proper one. |
| |
| This function gets a dictionary that was generated by parsing functions in |
| cherrypy or urllib and converts it into a dictionary that is has values in |
| proper format like True (as boolean) instead of 'True' (as string). It also |
| only converts values that nebraska knows about. It ignores other ones. |
| e.g: |
| {'foo': 'bar', 'test': 'True', 'cros': '12', 'hello': ['world', 'lord']} -> |
| {'foo': 'bar', 'test': True, 'cros': 12, 'hello': 'world'} |
| |
| Args: |
| query: A dictionary of key values. The values can either be a string or a |
| list. If the value is a list, only the first element of the list is used. |
| |
| Returns: |
| The converted dictionary. |
| """ |
| true_lambda = lambda a: a == 'True' |
| kwargs = {} |
| for k, t in { |
| 'critical_update': true_lambda, |
| 'disable_payload_backoff': true_lambda, |
| 'eol_date': int, |
| 'failures_per_url': int, |
| 'no_update': true_lambda, |
| 'num_urls': int, |
| 'full_payload': true_lambda, |
| }.items(): |
| value = query.get(k) |
| if value: |
| kwargs[k] = t(value[0] if isinstance(value, list) else value) |
| return kwargs |
| |
| 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 update and install |
| 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, nebraska, runtime_root=None, port=0): |
| """Initializes a server instance. |
| |
| Args: |
| nebraska: The Nebraska instance to process requests and responses. |
| runtime_root: The root directory in which nebraska will write its PID and |
| port files. |
| port: Port the server should run on, 0 if the OS should assign a port. |
| """ |
| self.nebraska = nebraska |
| self._runtime_root = runtime_root |
| self._runtime_files = {} |
| self._port = port |
| |
| self._httpd = None |
| self._server_thread = None |
| self._created_runtime_root = False |
| |
| class NebraskaHandler(server.BaseHTTPRequestHandler): |
| """HTTP request handler for Omaha requests.""" |
| |
| def _SendResponse(self, content_type, response, code=http.client.OK): |
| """Sends a given response back to the client. |
| |
| Args: |
| content_type: The content type of the response data: xml, json, etc. |
| response: The response content in string format. |
| code: The HTTP code to send back to the client. |
| """ |
| self.send_response(code) |
| self.send_header('Content-Type', content_type) |
| self.end_headers() |
| self.wfile.write(response) |
| |
| def _ParseURL(self, url): |
| """Parses a URL into usable components. |
| |
| Args: |
| url: The input URL to parse. |
| |
| Returns: |
| A tuple of parsed path and parsed query. The parsed query is a |
| dictionary of keys to list of values. e.g: |
| - http://goo.gle/path/?key=value1&key=value2 -> |
| ('path', {'key': ['value1', 'value2']}) |
| """ |
| parsed_result = urllib.parse.urlparse(url) |
| parsed_path = parsed_result.path.strip('/') |
| parsed_query = urllib.parse.parse_qs(parsed_result.query) |
| return parsed_path, parsed_query |
| |
| def do_POST(self): |
| """Responds to XML-formatted Omaha requests. |
| |
| The URL path can be like: |
| https://<ip>:<port>/update/?key1=value1&key2=value2... |
| |
| Look at ResponseProperties for a list of available attributes. |
| """ |
| try: |
| request_len = int(self.headers.get('content-length')) |
| data = self.rfile.read(request_len) |
| except Exception as err: |
| logging.error('Failed to read request in do_POST %s', str(err)) |
| self.send_error(http.client.BAD_REQUEST, 'Invalid request (header).') |
| return |
| |
| parsed_path, parsed_query = self._ParseURL(self.path) |
| try: |
| if parsed_path == 'update': |
| # TODO(b/181064515): Deprecate search query parsing after all users of |
| # nebraska changed to use `update_config` API instead. |
| self.server.owner.nebraska.UpdateConfig( |
| **QueryDictToDict(parsed_query)) |
| |
| response = self.server.owner.nebraska.GetResponseToRequest( |
| Request(data)) |
| self._SendResponse('application/xml', response) |
| |
| elif parsed_path == 'update_config': |
| self.server.owner.nebraska.UpdateConfig(**json.loads(data)) |
| self._SendResponse('text/plain', 'Config set!') |
| |
| else: |
| error_str = 'The requested path "%s" was not found!' % parsed_path |
| logging.error(error_str) |
| self.send_error(http.client.BAD_REQUEST, error_str) |
| |
| except Exception as err: |
| logging.error('Failed to handle request (%s)', str(err)) |
| logging.error(traceback.format_exc()) |
| self.send_error(http.client.INTERNAL_SERVER_ERROR, |
| traceback.format_exc()) |
| |
| def do_GET(self): |
| """Responds to Get requests. |
| |
| The use cases are: |
| - health_check: For health checking the Nebraska server. |
| |
| The URL path can be like: |
| https://<ip>:<port>/health_check |
| """ |
| parsed_path, _ = self._ParseURL(self.path) |
| |
| if parsed_path == 'health_check': |
| self._SendResponse('text/plain', 'Nebraska is alive!') |
| else: |
| logging.error('The requested path "%s" was not found!', parsed_path) |
| self.send_error(http.client.BAD_REQUEST, |
| 'The requested path "%s" was not found!' % parsed_path) |
| |
| def Start(self): |
| """Starts the nebraska server.""" |
| self._httpd = server.HTTPServer( |
| ('', self.GetPort()), NebraskaServer.NebraskaHandler) |
| self._port = self._httpd.server_port |
| |
| if self._runtime_root: |
| self._runtime_files = { |
| os.path.join(self._runtime_root, 'port'): self._port, |
| os.path.join(self._runtime_root, 'pid'): os.getpid(), |
| } |
| |
| try: |
| if not os.path.exists(self._runtime_root): |
| os.makedirs(self._runtime_root) |
| self._created_runtime_root = True |
| # Because the port and pid files might have been created but not written |
| # into, we create them in as temporary files and then move them to their |
| # final location so there won't be no race condition on the content of |
| # them. |
| for f, c in self._runtime_files.items(): |
| with open(f + '.partial', 'w') as fp: |
| fp.write(str(c)) |
| |
| for f in self._runtime_files: |
| shutil.move(f + '.partial', f) |
| |
| except IOError as err: |
| if err.errno == errno.EACCES: |
| print('Permission error: You need to run the script as root/sudo or ' |
| 'change the --runtime-root to point to a non-root accessible ' |
| 'location.') |
| raise |
| |
| self._httpd.owner = self |
| self._server_thread = threading.Thread(target=self._httpd.serve_forever) |
| self._server_thread.start() |
| |
| logging.info('Started nebraska on port %d and pid %d.', |
| self._port, os.getpid()) |
| |
| def Stop(self): |
| """Stops the mock Omaha server.""" |
| self._httpd.shutdown() |
| self._server_thread.join() |
| |
| if not self._runtime_root: |
| return |
| for f in self._runtime_files: |
| try: |
| os.remove(f) |
| except Exception as e: |
| logging.warning('Failed to remove file %s with error %s', f, e) |
| if self._created_runtime_root: |
| try: |
| shutil.rmtree(self._runtime_root) |
| except Exception as e: |
| logging.warning('Failed to remove directory %s with error %s', |
| self._runtime_root, e) |
| |
| |
| def GetPort(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__, |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
| |
| parser.add_argument('--update-metadata', metavar='DIR', default=None, |
| help='Payloads metadata directory for update.') |
| parser.add_argument('--install-metadata', metavar='DIR', default=None, |
| help='Payloads metadata directory for install.') |
| parser.add_argument('--update-payloads-address', metavar='URL', |
| help='Base payload URI for update payloads', |
| default='http://127.0.0.1:8080') |
| parser.add_argument('--install-payloads-address', metavar='URL', |
| help='Base payload URI for install payloads. If not ' |
| 'passed it will default to --update-payloads-address') |
| # TODO(b/181064515): Deprecate this argument after changing all users of |
| # nebraska to use `update_config` API. |
| parser.add_argument('--ignore-appid', action='store_true', |
| help='Ignore the App ID field of incoming requests and ' |
| 'use whichever app is available.') |
| |
| parser.add_argument('--port', metavar='PORT', type=int, default=0, |
| help='Port to run the server on.') |
| parser.add_argument('--runtime-root', metavar='DIR', |
| default='/run/nebraska', |
| help='The root directory in which nebraska will write its' |
| ' pid and port files.') |
| parser.add_argument('--log-file', metavar='FILE', default='/tmp/nebraska.log', |
| help='The file to write the logs.' |
| ' pass "stdout" to write to standard output.') |
| |
| return parser.parse_args(argv[1:]) |
| |
| |
| def main(argv): |
| """Main function.""" |
| opts = ParseArguments(argv) |
| |
| # Reset the log file. |
| if opts.log_file != 'stdout': |
| with open(opts.log_file, 'w') as _: |
| pass |
| print('Logging to %s' % opts.log_file) |
| |
| logging.basicConfig(filename=(opts.log_file if opts.log_file != 'stdout' |
| else None), |
| level=logging.DEBUG) |
| |
| logging.info('Starting nebraska ...') |
| |
| nebraska = Nebraska() |
| |
| # Attach '/' at the end of the addresses if they don't have any. The update |
| # engine just concatenates the base address with the payload file name and |
| # if there is no '/' the path will be invalid. |
| update_payloads_address = os.path.join(opts.update_payloads_address or '', '') |
| install_payloads_address = (os.path.join(opts.install_payloads_address or '', |
| '') or update_payloads_address) |
| |
| nebraska.UpdateConfig( |
| update_payloads_address=update_payloads_address, |
| install_payloads_address=install_payloads_address, |
| update_app_index=AppIndex(opts.update_metadata), |
| install_app_index=AppIndex(opts.install_metadata), |
| ignore_appid=opts.ignore_appid, |
| ) |
| nebraska_server = NebraskaServer(nebraska, runtime_root=opts.runtime_root, |
| port=opts.port) |
| |
| def handler(signum, _): |
| logging.info('Exiting Nebraska with signal %d ...', signum) |
| nebraska_server.Stop() |
| |
| signal.signal(signal.SIGINT, handler) |
| signal.signal(signal.SIGTERM, handler) |
| |
| nebraska_server.Start() |
| |
| signal.pause() |
| |
| return os.EX_OK |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(sys.argv)) |