diff --git a/azext_iot/central/_help.py b/azext_iot/central/_help.py index 98058f486..fc7263c5a 100644 --- a/azext_iot/central/_help.py +++ b/azext_iot/central/_help.py @@ -90,6 +90,28 @@ def _load_central_devices_help(): --device-id {deviceid} """ + helps[ + "iot central app device registration-info" + ] = """ + type: command + short-summary: Get registration info on device(s) from IoTC + long-summary: | + Note: This command can take a significant amount of time to return + if no device id is specified and your app contains a lot of devices + + examples: + - name: Get registration info on all devices. This command may take a long time to complete execution. + text: > + az iot central app device registration-info + --app-id {appid} + + - name: Get registration info on specified device + text: > + az iot central app device registration-info + --app-id {appid} + --device-id {deviceid} + """ + def _load_central_device_templates_help(): helps[ @@ -166,9 +188,8 @@ def _load_central_device_templates_help(): ] = """ type: command short-summary: Delete a device template from IoTC - long-summary: - Note: this is expected to fail - if any devices are still registered to this template. + long-summary: | + Note: this is expected to fail if any devices are still registered to this template. examples: - name: Get a device diff --git a/azext_iot/central/command_map.py b/azext_iot/central/command_map.py index f4a1250ac..996af1352 100644 --- a/azext_iot/central/command_map.py +++ b/azext_iot/central/command_map.py @@ -30,6 +30,7 @@ def load_central_commands(self, _): cmd_group.command("show", "get_device") cmd_group.command("create", "create_device") cmd_group.command("delete", "delete_device") + cmd_group.command("registration-info", "registration_info") with self.command_group( "iot central app device-template", diff --git a/azext_iot/central/commands_device.py b/azext_iot/central/commands_device.py index 745d92e31..5933bbb3f 100644 --- a/azext_iot/central/commands_device.py +++ b/azext_iot/central/commands_device.py @@ -9,15 +9,21 @@ from azext_iot.central.providers import CentralDeviceProvider -def list_devices(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): - provider = CentralDeviceProvider(cmd, app_id) +def list_devices( + cmd, app_id: str, token=None, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) return provider.list_devices() def get_device( - cmd, app_id: str, device_id: str, central_dns_suffix="azureiotcentral.com" + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix="azureiotcentral.com", ): - provider = CentralDeviceProvider(cmd, app_id) + provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) return provider.get_device(device_id) @@ -28,13 +34,14 @@ def create_device( device_name=None, instance_of=None, simulated=False, + token=None, central_dns_suffix="azureiotcentral.com", ): if simulated and not instance_of: raise CLIError( "Error: if you supply --simulated you must also specify --instance-of" ) - provider = CentralDeviceProvider(cmd, app_id) + provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) return provider.create_device( device_id=device_id, device_name=device_name, @@ -45,7 +52,26 @@ def create_device( def delete_device( - cmd, app_id: str, device_id: str, central_dns_suffix="azureiotcentral.com" + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix="azureiotcentral.com", ): - provider = CentralDeviceProvider(cmd, app_id) + provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) return provider.delete_device(device_id) + + +def registration_info( + cmd, + app_id: str, + device_id=None, + token=None, + central_dns_suffix="azureiotcentral.com", +): + provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + if not device_id: + return provider.get_all_registration_info(central_dns_suffix=central_dns_suffix) + return provider.get_device_registration_info( + device_id=device_id, central_dns_suffix=central_dns_suffix + ) diff --git a/azext_iot/central/commands_device_template.py b/azext_iot/central/commands_device_template.py index 2f2050f95..8a497a45d 100644 --- a/azext_iot/central/commands_device_template.py +++ b/azext_iot/central/commands_device_template.py @@ -12,21 +12,29 @@ def get_device_template( - cmd, app_id: str, device_template_id: str, central_dns_suffix="azureiotcentral.com" + cmd, + app_id: str, + device_template_id: str, + token=None, + central_dns_suffix="azureiotcentral.com", ): - provider = CentralDeviceTemplateProvider(cmd, app_id) + provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) return provider.get_device_template( device_template_id=device_template_id, central_dns_suffix=central_dns_suffix ) -def list_device_templates(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): - provider = CentralDeviceTemplateProvider(cmd, app_id) +def list_device_templates( + cmd, app_id: str, token=None, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) return provider.list_device_templates(central_dns_suffix=central_dns_suffix) -def map_device_templates(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): - provider = CentralDeviceTemplateProvider(cmd, app_id) +def map_device_templates( + cmd, app_id: str, token=None, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) return provider.map_device_templates(central_dns_suffix=central_dns_suffix) @@ -35,6 +43,7 @@ def create_device_template( app_id: str, device_template_id: str, content: str, + token=None, central_dns_suffix="azureiotcentral.com", ): if not isinstance(content, str): @@ -42,7 +51,7 @@ def create_device_template( payload = utility.process_json_arg(content, argument_name="content") - provider = CentralDeviceTemplateProvider(cmd, app_id) + provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) return provider.create_device_template( device_template_id=device_template_id, payload=payload, @@ -51,9 +60,13 @@ def create_device_template( def delete_device_template( - cmd, app_id: str, device_template_id: str, central_dns_suffix="azureiotcentral.com" + cmd, + app_id: str, + device_template_id: str, + token=None, + central_dns_suffix="azureiotcentral.com", ): - provider = CentralDeviceTemplateProvider(cmd, app_id) + provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) return provider.delete_device_template( device_template_id=device_template_id, central_dns_suffix=central_dns_suffix ) diff --git a/azext_iot/central/params.py b/azext_iot/central/params.py index a158a969f..b39fa4ebe 100644 --- a/azext_iot/central/params.py +++ b/azext_iot/central/params.py @@ -46,3 +46,11 @@ def load_central_arguments(self, _): "[File Path Example: ./path/to/file.json] " "[Stringified JSON Example: {'a': 'b'}] ", ) + context.argument( + "token", + options_list=["--token"], + help="Authorization token for request. " + "More info available here: https://docs.microsoft.com/en-us/learn/modules/manage-iot-central-apps-with-rest-api/ " + "MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...'). " + "Example: 'Bearer someBearerTokenHere'", + ) diff --git a/azext_iot/central/providers/device_provider.py b/azext_iot/central/providers/device_provider.py index c45becb92..505574aa9 100644 --- a/azext_iot/central/providers/device_provider.py +++ b/azext_iot/central/providers/device_provider.py @@ -5,7 +5,11 @@ # -------------------------------------------------------------------------------------------- from knack.util import CLIError +from knack.log import get_logger from azext_iot.central import services as central_services +from azext_iot.dps.services import global_service as dps_global_service + +logger = get_logger(__name__) class CentralDeviceProvider: @@ -26,13 +30,14 @@ def __init__(self, cmd, app_id: str, token=None): self._token = token self._devices = {} self._device_templates = {} + self._device_credentials = {} + self._device_registration_info = {} def get_device( self, device_id, central_dns_suffix="azureiotcentral.com", ): if not device_id: raise CLIError("Device id must be specified.") - # get or add to cache device = self._devices.get(device_id) if not device: @@ -137,5 +142,72 @@ def delete_device( # remove from cache # pop "miss" raises a KeyError if None is not provided self._devices.pop(device_id, None) + self._device_credentials.pop(device_id, None) + + return result + + def get_device_credentials( + self, device_id, central_dns_suffix="azureiotcentral.com", + ): + credentials = self._device_credentials.get(device_id) + + if not credentials: + credentials = central_services.device.get_device_credentials( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + ) + + if not credentials: + raise CLIError( + "Could not find device credentials for device '{}'".format(device_id) + ) + + # add to cache + self._device_credentials[device_id] = credentials + + return credentials + + def get_device_registration_info( + self, device_id, central_dns_suffix="azureiotcentral.com", + ): + info = self._device_registration_info.get(device_id) + + if not info: + credentials = self.get_device_credentials( + device_id=device_id, central_dns_suffix=central_dns_suffix + ) + id_scope = credentials["idScope"] + key = credentials["symmetricKey"]["primaryKey"] + dps_state = dps_global_service.get_registration_state( + id_scope=id_scope, key=key, device_id=device_id + ) + central_info = self.get_device(device_id) + info = { + "@device_id": device_id, + "dps_state": dps_state, + "central_info": central_info, + } + + self._device_registration_info[device_id] = info + + return info + + def get_all_registration_info(self, central_dns_suffix="azureiotcentral.com"): + logger.warning("This command may take a long time to complete execution.") + devices = self.list_devices(central_dns_suffix=central_dns_suffix) + real_devices = [ + device for device in devices.values() if not device["simulated"] + ] + if len(devices) != len(real_devices): + logger.warning( + "Getting registration info for following devices. " + "All other devices are simulated. " + "{}".format([device["id"] for device in real_devices]) + ) + result = [ + self.get_device_registration_info(device["id"]) for device in real_devices + ] return result diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py index 46e221bcc..717eb89c7 100644 --- a/azext_iot/central/services/device.py +++ b/azext_iot/central/services/device.py @@ -145,3 +145,33 @@ def delete_device( response = requests.delete(url, headers=headers) return _utility.try_extract_result(response) + + +def get_device_credentials( + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix="azureiotcentral.com", +): + """ + Get device credentials from IoTC + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id, + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + device_credentials: dict + """ + url = "https://{}.{}/{}/{}/credentials".format( + app_id, central_dns_suffix, BASE_PATH, device_id + ) + headers = _utility.get_headers(token, cmd) + + response = requests.get(url, headers=headers) + return _utility.try_extract_result(response) diff --git a/azext_iot/dps/__init__.py b/azext_iot/dps/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/dps/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/dps/services/__init__.py b/azext_iot/dps/services/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/dps/services/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/dps/services/auth.py b/azext_iot/dps/services/auth.py new file mode 100644 index 000000000..8ddc1f468 --- /dev/null +++ b/azext_iot/dps/services/auth.py @@ -0,0 +1,28 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import time +import base64 +import hmac +import hashlib +import urllib + + +def get_dps_sas_auth_header( + scope_id, device_id, key, +): + sr = "{}%2Fregistrations%2F{}".format(scope_id, device_id) + expires = int(time.time() + 21600) + registration_id = f"{sr}\n{str(expires)}" + secret = base64.b64decode(key) + signature = base64.b64encode( + hmac.new( + secret, msg=registration_id.encode("utf8"), digestmod=hashlib.sha256 + ).digest() + ) + quote_signature = urllib.parse.quote(signature, "~()*!.'") + token = f"SharedAccessSignature sr={sr}&sig={quote_signature}&se={str(expires)}&skn=registration" + return token diff --git a/azext_iot/dps/services/global_service.py b/azext_iot/dps/services/global_service.py new file mode 100644 index 000000000..bd972dcf1 --- /dev/null +++ b/azext_iot/dps/services/global_service.py @@ -0,0 +1,46 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# This is for calls that route to the global DPS +# Useful for when you don't know what the dps name is ahead of time +# E.g. most IoT Central scenarios + +import requests + +from azext_iot.dps.services import auth + + +def get_registration_state(id_scope: str, key: str, device_id: str): + """ + Gets device registration state from global dps endpoint + Usefule for when dps name is unknown + + https://docs.microsoft.com/en-us/rest/api/iot-dps/getdeviceregistrationstate/getdeviceregistrationstate + + Params: + id_scope: dps id_scope + key: either primary or secondary symmetric key + device_id: device id that uniquely identifies the device + + Returns: + DeviceRegistrationState: dict + ProvisioningServiceErrorDetails: dict + """ + authToken = auth.get_dps_sas_auth_header(id_scope, device_id, key) + + url = "https://global.azure-devices-provisioning.net/{}/registrations/{}?api-version=2019-03-31".format( + id_scope, device_id + ) + header_parameters = {} + header_parameters["Content-Type"] = "application/json" + header_parameters["Authorization"] = "{}".format(authToken) + body = {"registrationId": "{}".format(device_id)} + response = requests.post(url, headers=header_parameters, json=body) + + try: + response.raise_for_status() + return response.json() + except Exception as e: + return {"error": str(e), "device_id": device_id} diff --git a/azext_iot/tests/test_iot_central_int.py b/azext_iot/tests/test_iot_central_int.py index 524d1b8f0..094f5c1d4 100644 --- a/azext_iot/tests/test_iot_central_int.py +++ b/azext_iot/tests/test_iot_central_int.py @@ -5,6 +5,8 @@ # -------------------------------------------------------------------------------------------- import os +import time + from azure.cli.testsdk import LiveScenarioTest from azext_iot.common import utility @@ -148,3 +150,38 @@ def test_central_device_template_methods_CRLD(self): map_json = map_output.get_output_in_json() assert map_json[template_name] == template_id + + def test_central_device_registration_info(self): + device_id = self.create_random_name(prefix="aztest", length=24) + device_name = self.create_random_name(prefix="aztest", length=24) + # currently: create, show, list, delete + self.cmd( + "iot central app device create --app-id {} -d {} --device-name {}".format( + APP_ID, device_id, device_name + ), + checks=[ + self.check("approved", True), + self.check("displayName", device_name), + self.check("id", device_id), + self.check("simulated", False), + ], + ) + + result = self.cmd( + "iot central app device registration-info --app-id {} -d {}".format( + APP_ID, device_id + ) + ) + + self.cmd( + "iot central app device delete --app-id {} -d {}".format(APP_ID, device_id), + checks=[self.check("result", "success")], + ) + + json_result = result.get_output_in_json() + assert json_result["@device_id"] == device_id + + # since time taken for provisioning to complete is not known + # we can only assert that the payload is populated, not anything specific beyond that + assert json_result["central_info"] is not None + assert json_result["dps_state"] is not None