Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Edge device creation, configuration, and device bundles #591

Merged
merged 73 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
4f8f6b8
Initial nested edge hierarchy implementation
c-ryan-k Sep 7, 2022
abb1598
Moved edge hierarchy command into provider pattern
c-ryan-k Sep 27, 2022
1ea29c7
Remove Default Edge Agent from edge hierarchy config
c-ryan-k Sep 27, 2022
abec0a5
Linter fixes
c-ryan-k Sep 28, 2022
5235b3f
Pattern updates and initial tests
c-ryan-k Oct 3, 2022
1f4c374
Cleanup / linting / refactoring, test updates
c-ryan-k Oct 4, 2022
a84c21e
Added integration tests and minor fix in multi-step hierarchy logic
c-ryan-k Oct 6, 2022
e216547
Help Updates
c-ryan-k Oct 6, 2022
7c926d9
Added deployment config processing and unit tests
c-ryan-k Oct 6, 2022
db4935e
Updated YAML parsing exception handling
c-ryan-k Oct 6, 2022
1247ef3
Merge branch 'azure-dev' into nested_edge_rebase
c-ryan-k Oct 6, 2022
1b2b37a
Cleanup
c-ryan-k Oct 6, 2022
7454f68
Merge branch 'dev' into nested_edge_rebase
c-ryan-k Oct 25, 2022
dceb0a0
Wave of PR feedback
c-ryan-k Oct 25, 2022
5dd9723
Certificate creation for nested edge devices
c-ryan-k Oct 11, 2022
fc7168e
Device config / bundle updates, minor fixes
c-ryan-k Oct 11, 2022
a6b9f28
Refactoring / fixes / device bundle improvements
c-ryan-k Oct 17, 2022
84be078
Added test files for e2e tests
c-ryan-k Oct 18, 2022
ce2fc51
Attempted to fix CA/CSR cert signing, notes for necessary unit tests
c-ryan-k Oct 19, 2022
8df074f
File operation tests
c-ryan-k Oct 20, 2022
a62231a
Some cert and file refactoring
c-ryan-k Oct 20, 2022
511fc19
Configuration script utility tests
c-ryan-k Oct 20, 2022
7f592ee
Updated tests
c-ryan-k Oct 24, 2022
32131d8
Refactoring nested edge logic to be a bit cleaner, updated test
c-ryan-k Oct 25, 2022
d01c4e3
Help / param updates
c-ryan-k Oct 25, 2022
a6f6ac2
Updates / refactoring / tests
c-ryan-k Oct 25, 2022
04a6bce
Testing updates
c-ryan-k Oct 27, 2022
8c54d4d
Refactoring and linting
c-ryan-k Oct 27, 2022
d079c17
Removes TypedDict usage
c-ryan-k Oct 27, 2022
ac034f4
Credscan fixes
c-ryan-k Oct 27, 2022
aae16c2
Refactored edge_device_config members into separate module
c-ryan-k Oct 28, 2022
0f3a8b9
Help and parameter updates
c-ryan-k Oct 28, 2022
c7abbf5
Various minor tweaks
c-ryan-k Oct 28, 2022
8d20427
Delete test.yml
c-ryan-k Oct 31, 2022
1f3edc6
Delete extra test config files
c-ryan-k Oct 31, 2022
83b0931
Certificate updates
c-ryan-k Oct 31, 2022
e7843c4
Parameter updates
c-ryan-k Oct 31, 2022
f90d195
Fixed parent hostname processing from inline device args
c-ryan-k Nov 3, 2022
1be54ec
Wave of PR feedback
c-ryan-k Nov 4, 2022
3346a49
Second wave of updates
c-ryan-k Nov 5, 2022
d6cd387
Added tomli and tomli-w to setup, thirdpartnotices
c-ryan-k Nov 5, 2022
14ff898
Linting and style fixes
c-ryan-k Nov 5, 2022
6e42a2f
Oops
c-ryan-k Nov 5, 2022
f078ba3
Replaced old usage of toml with tomli
c-ryan-k Nov 7, 2022
7870cef
Snap to schema
c-ryan-k Nov 8, 2022
4dc4da9
Various minor tweaks
c-ryan-k Nov 10, 2022
806d2cb
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Nov 10, 2022
0afab5c
Testing updates
c-ryan-k Nov 15, 2022
2ff6662
Refactor of edge_device_config into iothub/providers/helpers
c-ryan-k Nov 16, 2022
b256cb6
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Nov 16, 2022
db13535
Updates to device hub-auth certificate generation, minor refactoring
c-ryan-k Nov 23, 2022
8cf9705
Wave of PR feedback
c-ryan-k Dec 12, 2022
1814463
Added confirmation to '--clean' arg (and '--yes' bypass)
c-ryan-k Dec 12, 2022
719881b
Added retry logic to device scope query step
c-ryan-k Dec 12, 2022
46a9a63
Test updates
c-ryan-k Dec 12, 2022
6b75494
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Dec 12, 2022
a8a9158
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Dec 15, 2022
699ef4e
Fix default value for --clean prompt
c-ryan-k Dec 19, 2022
ecfc27f
load CA certs with read_file_content instead of open_certificate
c-ryan-k Dec 19, 2022
ce109fa
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Dec 22, 2022
5a72266
Merge branch 'dev' into nested_edge_device_bundle
c-ryan-k Dec 30, 2022
2cb15b3
Added encoding and license info to fileops.py
c-ryan-k Jan 6, 2023
c4ab59d
Merge branch 'nested_edge_device_bundle' of https://github.com/c-ryan…
c-ryan-k Jan 7, 2023
08325d9
Consolidated root certificate creation logic
c-ryan-k Jan 6, 2023
d09c8ee
PR feedback items
c-ryan-k Jan 7, 2023
23a2024
Consolidated multiple instances of assemble_nargs_to_dict to utility …
c-ryan-k Jan 7, 2023
26c5e35
Removed confirm boolean from delete_device_identities
c-ryan-k Jan 7, 2023
087e221
Constrained dependency versions for tomli, tomli-w, treelib
c-ryan-k Jan 7, 2023
631ca9a
Minor PR feedback
c-ryan-k Jan 7, 2023
d259441
Fixed another valid_days typing
c-ryan-k Jan 9, 2023
1c67046
Fixed incorrect format string specifier
c-ryan-k Jan 9, 2023
605c997
Configure relative paths for config file values
c-ryan-k Jan 10, 2023
07d905f
Add history entry for `edge devices create` command
c-ryan-k Jan 10, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,8 @@ __pycache__/

# Virtual environment
env/
env27/
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
env36/
env2*/
env3*/
.python-version

# PTVS analysis
Expand Down Expand Up @@ -324,3 +324,4 @@ src/build
*.sh

/extensions
device_bundles
212 changes: 198 additions & 14 deletions azext_iot/common/certops.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
"""

import datetime
from os.path import exists, join
from os.path import exists
import base64
from typing import Dict
from typing import List, Optional
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from azext_iot.common.shared import SHAHashVersions
from azext_iot.common.fileops import write_content_to_file
from azext_iot.common.shared import SHAHashVersions, CertInfo
from azure.cli.core.azclierror import FileOperationError


def create_self_signed_certificate(
Expand All @@ -26,7 +28,7 @@ def create_self_signed_certificate(
cert_only: bool = False,
file_prefix: str = None,
sha_version: int = SHAHashVersions.SHA1.value,
) -> Dict[str, str]:
) -> CertInfo:
"""
Function used to create a basic self-signed certificate with no extensions.

Expand Down Expand Up @@ -85,19 +87,26 @@ def create_self_signed_certificate(
if cert_output_dir and exists(cert_output_dir):
cert_file = (file_prefix or subject) + "-cert.pem"
key_file = (file_prefix or subject) + "-key.pem"

with open(join(cert_output_dir, cert_file), "wt", encoding="utf-8") as f:
f.write(cert_dump)
write_content_to_file(
content=cert_dump,
destination=cert_output_dir,
file_name=cert_file,
overwrite=True,
)

if not cert_only:
with open(join(cert_output_dir, key_file), "wt", encoding="utf-8") as f:
f.write(key_dump)
write_content_to_file(
content=key_dump,
destination=cert_output_dir,
file_name=key_file,
overwrite=True,
)

result = {
"certificate": cert_dump,
"privateKey": key_dump,
"thumbprint": thumbprint,
}
result = CertInfo(
certificate=cert_dump,
privateKey=key_dump,
thumbprint=thumbprint,
)

return result

Expand Down Expand Up @@ -125,3 +134,178 @@ def open_certificate(certificate_path: str) -> str:
raise ValueError("Certificate file type must be either '.pem' or '.cer'.")
# Remove trailing white space from the certificate content
return certificate.rstrip()


# TODO - Unit test, compare with test_utils::_generate_root_certificate
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
def create_root_certificate(
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
subject: Optional[str] = "Azure_IoT_CLI_Extension_Cert",
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
valid_days: Optional[int] = 365,
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
key_size: Optional[int] = 4096
) -> CertInfo:
key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)

subject_name = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, subject),
]
)

# v3_ca extensions
subject_key_id = x509.SubjectKeyIdentifier.from_public_key(key.public_key())
authority_key_id = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
subject_key_id
)
basic = x509.BasicConstraints(ca=True, path_length=None)
key_usage = x509.KeyUsage(
digital_signature=True,
crl_sign=True,
key_cert_sign=True,
content_commitment=False,
data_encipherment=False,
decipher_only=False,
encipher_only=False,
key_agreement=False,
key_encipherment=False,
)
cert = (
x509.CertificateBuilder()
.subject_name(subject_name)
.issuer_name(subject_name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=valid_days))
.add_extension(subject_key_id, critical=False)
.add_extension(authority_key_id, critical=False)
.add_extension(basic, critical=True)
.add_extension(key_usage, critical=True)
.sign(key, hashes.SHA256())
)
certificate = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").rstrip()
thumbprint = cert.fingerprint(hashes.SHA256()).hex().upper()
private_key = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8").rstrip()

return CertInfo(
certificate=certificate,
privateKey=private_key,
thumbprint=thumbprint,
)


# TODO - Unit test, compare with test_utils::_generate_device_certificate
def create_signed_cert(
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
subject: str,
ca_public: str,
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
ca_private: str,
cert_output_dir: Optional[str] = None,
cert_file: Optional[str] = None,
valid_days: Optional[int] = 365,
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
) -> CertInfo:

private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
ca_public_key = ca_public.encode("utf-8")
ca_private_key = ca_private.encode("utf-8")
ca_key = serialization.load_pem_private_key(ca_private_key, password=None)
ca_cert = x509.load_pem_x509_certificate(ca_public_key)

# v3 certificate extensions
subject_key_id = x509.SubjectKeyIdentifier.from_public_key(private_key.public_key())
authority_key_id = x509.AuthorityKeyIdentifier.from_issuer_public_key(
ca_cert.public_key()
)
basic_constraints = x509.BasicConstraints(ca=True, path_length=None)
key_usage = x509.KeyUsage(
digital_signature=True,
crl_sign=True,
key_cert_sign=True,
content_commitment=False,
data_encipherment=False,
decipher_only=False,
encipher_only=False,
key_agreement=False,
key_encipherment=False,
)
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(
x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, subject),
]
)
)
.sign(private_key, hashes.SHA256())
)
cert = (
x509.CertificateBuilder()
.subject_name(csr.subject)
.issuer_name(ca_cert.subject)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=valid_days))
.add_extension(subject_key_id, False)
.add_extension(authority_key_id, False)
.add_extension(basic_constraints, True)
.add_extension(key_usage, True)
.sign(ca_key, hashes.SHA256())
)
certificate = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
thumbprint = cert.fingerprint(hashes.SHA256()).hex().upper()
privateKey = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")

if cert_output_dir and exists(cert_output_dir):
write_content_to_file(
content=certificate,
destination=cert_output_dir,
file_name=f"{cert_file or subject}.cert.pem",
)
write_content_to_file(
content=privateKey,
destination=cert_output_dir,
file_name=f"{cert_file or subject}.key.pem",
)
return CertInfo(
certificate=certificate, thumbprint=thumbprint, privateKey=privateKey
)


def load_ca_cert_info(cert_path: str, key_path: str) -> CertInfo:
for path in [cert_path, key_path]:
if not exists(path):
raise FileOperationError(
"Error loading certificates. " f"No file found at path '{path}'"
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
)
key = open_certificate(key_path)
cert = open_certificate(cert_path).encode("utf-8")
certificate = x509.load_pem_x509_certificate(cert)
thumbprint = certificate.fingerprint(hashes.SHA256()).hex().upper()
return CertInfo(
certificate=certificate.public_bytes(serialization.Encoding.PEM).decode("utf-8").rstrip(),
thumbprint=thumbprint,
privateKey=key,
)


def make_cert_chain(
certs: List[str],
output_dir: Optional[str] = None,
output_file: Optional[str] = "cert-chain.pem",
):
cert_content = "".join(certs)
vilit1 marked this conversation as resolved.
Show resolved Hide resolved
if output_dir and exists(output_dir) and len(certs):
write_content_to_file(
content=cert_content,
destination=output_dir,
file_name=output_file,
overwrite=True,
)
return cert_content
47 changes: 47 additions & 0 deletions azext_iot/common/fileops.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from os import makedirs, remove, listdir
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
from os.path import exists, join
from pathlib import PurePath
from typing import Optional, Union
from azure.cli.core.azclierror import FileOperationError


"""
fileops: Functions for working with files.
"""


def write_content_to_file(
content: Union[str, bytes],
destination: str,
file_name: str,
overwrite: Optional[bool] = False,
):
dest_path = PurePath(destination)
file_path = dest_path.joinpath(file_name)

if exists(file_path) and not overwrite:
raise FileOperationError(f"File already exists at path: {file_path}")
if overwrite and destination:
makedirs(destination, exist_ok=True)
write_content = bytes(content, "utf-8") if isinstance(content, str) else content
with open(file_path, "wb") as f:
f.write(write_content)


def tar_directory(
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
target_directory: str,
tarfile_path: str,
tarfile_name: str,
overwrite: Optional[bool] = False,
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
):
full_path = join(tarfile_path, f"{tarfile_name}.tgz")
if exists(full_path):
if not overwrite:
raise FileOperationError(f"File {full_path} already exists")
remove(full_path)
if not exists(tarfile_path):
makedirs(tarfile_path, exist_ok=overwrite)
import tarfile
with tarfile.open(full_path, "w:gz") as tar:
for file_name in listdir(target_directory):
tar.add(join(target_directory, file_name), file_name)
47 changes: 45 additions & 2 deletions azext_iot/common/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"""

from enum import Enum
from typing import NamedTuple, Optional, Any, TypedDict, List
from azext_iot.sdk.iothub.service.models import ConfigurationContent


class SdkType(Enum):
Expand Down Expand Up @@ -263,6 +265,7 @@ class IoTDPSStateType(Enum):
"""
IoT Hub Device Provisioning Service State Property
"""

Activating = "Activating"
ActivationFailed = "ActivationFailed"
Active = "Active"
Expand All @@ -279,12 +282,13 @@ class IoTDPSStateType(Enum):

class ConnectionStringParser(Enum):
"""
All connection string parser with respective functions
All connection string parser with respective functions
"""

from azext_iot.common._azure import (
parse_iot_device_connection_string,
parse_iot_device_module_connection_string,
parse_iot_hub_connection_string
parse_iot_hub_connection_string,
)

Module = parse_iot_device_module_connection_string
Expand All @@ -296,6 +300,7 @@ class DiscoveryResourceType(Enum):
"""
Resource types supported by discovery.
"""

IoTHub = "IoT Hub"
DPS = "IoT Hub Device Provisioning Service"

Expand All @@ -304,5 +309,43 @@ class SHAHashVersions(Enum):
"""
Supported SHA types for generating the certificate thumbprint.
"""

SHA1 = 1
SHA256 = 256


class CertInfo(TypedDict):
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
"""
Typed Dict for certificate, thumbprint, and private key return type
"""

certificate: str
privateKey: str
thumbprint: str


# Utility classes for nested edge config file values and device arguments
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
class EdgeContainerAuth(NamedTuple):
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
serveraddress: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None


class NestedEdgeDeviceConfig(NamedTuple):
device_id: str
c-ryan-k marked this conversation as resolved.
Show resolved Hide resolved
deployment: Optional[ConfigurationContent] = None
config: Optional[Any] = None
parent_id: Optional[str] = None
hostname: Optional[str] = None
parent_hostname: Optional[str] = None
edge_agent: Optional[str] = None
container_auth: Optional[EdgeContainerAuth] = None


class NestedEdgeConfig(NamedTuple):
version: str
auth_method: DeviceAuthType
root_cert: CertInfo
devices: List[NestedEdgeDeviceConfig]
template_config_path: Optional[str] = None
default_edge_agent: Optional[str] = None
Loading