From 638448c6775dee0227b3613c277f931a7c5d34be Mon Sep 17 00:00:00 2001 From: Matthew Howle Date: Thu, 23 Mar 2023 11:58:05 -0400 Subject: [PATCH 1/6] Fix docs for DNS delegation --- certbot_dns_azure/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certbot_dns_azure/__init__.py b/certbot_dns_azure/__init__.py index 123c9ca..f5733b1 100644 --- a/certbot_dns_azure/__init__.py +++ b/certbot_dns_azure/__init__.py @@ -123,7 +123,7 @@ DNS delegation, also known as DNS aliasing, is a process of allowing a secondary DNS zone to handle validation in place of the primary zone. For example, you would like to acquire a certificate for ``example.com`` but have the validation performed on a secondary domain ``example.org``. You would create a ``_acme-challenge.example.com`` CNAME on the -``example.com`` nameserver with the value of ``_acme-challenge.example.org``. Certbot will resolve the CNAME and +``example.com`` nameserver with the value of ``_acme-challenge.example.com.example.org``. Certbot will resolve the CNAME and validate the ``example.com`` domain. The common reasons for DNS delegation are: @@ -131,7 +131,7 @@ * Security concerns regarding access to the primary DNS zone To use DNS delegation: - #. Manually create the ``_acme-challenge.`` CNAME with the value ``_acme-challenge.`` on the ``example.com`` nameserver. + #. Manually create the ``_acme-challenge.`` CNAME with the value ``_acme-challenge..`` on the primary domain nameserver. #. In the certbot azure configuration file, specify the primary domain and the entire secondary DNS zone's resource ID in ``dns_azure_zoneX``. #. Request the certificate. From b9e35263373bfca6c5cab176ef9375317b712273 Mon Sep 17 00:00:00 2001 From: Terry Cain Date: Sat, 8 Apr 2023 00:14:19 +0100 Subject: [PATCH 2/6] Added real Azure tests & improved aliasing support --- README.md | 4 +- certbot_dns_azure/_internal/dns_azure.py | 77 ++++++--- tests/integration_test.py | 205 +++++++++++++++++++++++ 3 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 tests/integration_test.py diff --git a/README.md b/README.md index 08b71b7..e175237 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python Version](https://img.shields.io/pypi/pyversions/certbot-dns-azure)](https://pypi.org/project/certbot-dns-azure/) [![PyPi Status](https://img.shields.io/pypi/status/certbot-dns-azure)](https://pypi.org/project/certbot-dns-azure/) [![Version](https://img.shields.io/pypi/v/certbot-dns-azure)](https://pypi.org/project/certbot-dns-azure/) -[![Docs](https://readthedocs.org/projects/certbot-dns-azure/badge/?version=latest&style=flat)](https://certbot-dns-azure.readthedocs.io/en/latest/) +[![Docs](https://readthedocs.org/projects/certbot-dns-azure/badge/?version=latest&style=flat)](https://docs.certbot-dns-azure.co.uk/en/latest/) AzureDNS Authenticator plugin for [Certbot](https://certbot.eff.org/). @@ -48,6 +48,6 @@ Entry point: dns-azure = certbot_dns_azure.dns_azure:Authenticator ... ``` -Docs and instructions on configuration are [here](https://certbot-dns-azure.readthedocs.io/en/latest/) +Docs and instructions on configuration are [here](https://docs.certbot-dns-azure.co.uk/en/latest/) diff --git a/certbot_dns_azure/_internal/dns_azure.py b/certbot_dns_azure/_internal/dns_azure.py index ec206dd..e912c1e 100644 --- a/certbot_dns_azure/_internal/dns_azure.py +++ b/certbot_dns_azure/_internal/dns_azure.py @@ -1,7 +1,7 @@ """DNS Authenticator for Azure DNS.""" import logging from os import getenv -from typing import Dict +from typing import Dict, Tuple from azure.mgmt.dns import DnsManagementClient from azure.mgmt.dns.models import RecordSet, TxtRecord @@ -154,21 +154,47 @@ def _get_azure_credentials(client_id=None, client_secret=None, certificate_path= else: return ManagedIdentityCredential() - def _get_ids_for_domain(self, domain: str): + def _get_ids_for_domain(self, domain: str, validation_name: str) -> Tuple[str, str, str, str, bool]: + """ + :param domain: Domain/subdomain to look up the closest parent in the config file + :param validation_name: DNS challenge record name, fully qualified + + This returns: + * The Azure DNS zone for which to add records to + * The subscription ID for said zone + * The resource group for said zone + * The relative validation record name (or if explicitly overrided with an ID, an alternate record name) + * If the validation record can be deleted, if its explicitly overrided, it wont be deleted but set to `-` + """ + # So if the config contains domain.io and test.domain.io + # and we want to renew, we'd prefer test.domain.io. + # Sort domains by longest first and then attempt to find the right one. + # This should work better, as then a.b.test.domain.io would pick domain.io irrelevant + # of its order in the config + azure_domains = sorted(self.domain_zoneid.keys(), key=lambda domain: len(domain), reverse=True) + try: - for azure_dns_domain, resource_group in self.domain_zoneid.items(): + for azure_dns_domain in azure_domains: # Look to see if domain ends with key, to cover subdomains if domain.endswith(azure_dns_domain): + zone_id = self.domain_zoneid[azure_dns_domain] + try: - resource = self.parse_azure_resource_id(resource_group) + resource = self.parse_azure_resource_id(zone_id) except ValueError as exc: raise errors.PluginError('Failed to parse resource ID for {}: {}' - .format(domain, resource_group)) from exc + .format(domain, zone_id)) from exc subscription_id = resource.get('subscriptions') rg_name = resource.get('resourceGroups') - if 'dnsZones' in resource: + if 'dnsZones' in resource: # If we're manually specifying an alternate zone to use, override. azure_dns_domain = resource.get('dnsZones') - return azure_dns_domain, subscription_id, rg_name + relative_validation_name = self._get_relative_domain(validation_name, azure_dns_domain) + can_delete = True + if 'TXT' in resource: # If we're explicitly specifing a destination record, use instead. + relative_validation_name = resource.get('TXT') + can_delete = False # If we're specifying a specific record, dont delete it + + return azure_dns_domain, subscription_id, rg_name, relative_validation_name, can_delete else: raise errors.PluginError('Domain {} does not have a valid domain to ' 'resource group id mapping'.format(domain)) @@ -182,9 +208,8 @@ def _get_relative_domain(fqdn: str, domain: str) -> str: return fqdn.replace(domain, '').strip('.') def _perform(self, domain, validation_name, validation): - azure_domain, subscription_id, resource_group_name = self._get_ids_for_domain(domain) + azure_domain, subscription_id, resource_group_name, validation_name, _ = self._get_ids_for_domain(domain, validation_name) client = self._get_azure_client(subscription_id) - relative_validation_name = self._get_relative_domain(validation_name, azure_domain) # Check to see if there are any existing TXT validation record values txt_value = {validation} @@ -192,10 +217,12 @@ def _perform(self, domain, validation_name, validation): existing_rr = client.record_sets.get( resource_group_name=resource_group_name, zone_name=azure_domain, - relative_record_set_name=relative_validation_name, + relative_record_set_name=validation_name, record_type='TXT') for record in existing_rr.txt_records: for value in record.value: + if value == '-': + continue txt_value.add(value) except HttpResponseError as err: if err.status_code != 404: # Ignore RR not found @@ -206,7 +233,7 @@ def _perform(self, domain, validation_name, validation): client.record_sets.create_or_update( resource_group_name=resource_group_name, zone_name=azure_domain, - relative_record_set_name=relative_validation_name, + relative_record_set_name=validation_name, record_type='TXT', parameters=RecordSet(ttl=self.ttl, txt_records=[TxtRecord(value=[v]) for v in txt_value]) ) @@ -218,15 +245,14 @@ def _cleanup(self, domain, validation_name, validation): if self.credential is None: self._setup_credentials() - azure_domain, subscription_id, resource_group_name = self._get_ids_for_domain(domain) - relative_validation_name = self._get_relative_domain(validation_name, azure_domain) + azure_domain, subscription_id, resource_group_name, validation_name, can_delete = self._get_ids_for_domain(domain, validation_name) client = self._get_azure_client(subscription_id) txt_value = set() try: existing_rr = client.record_sets.get(resource_group_name=resource_group_name, zone_name=azure_domain, - relative_record_set_name=relative_validation_name, + relative_record_set_name=validation_name, record_type='TXT') for record in existing_rr.txt_records: for value in record.value: @@ -243,18 +269,27 @@ def _cleanup(self, domain, validation_name, validation): client.record_sets.create_or_update( resource_group_name=resource_group_name, zone_name=azure_domain, - relative_record_set_name=relative_validation_name, + relative_record_set_name=validation_name, record_type='TXT', parameters=RecordSet(ttl=self.ttl, txt_records=[TxtRecord(value=[v]) for v in txt_value]) ) else: - client.record_sets.delete( - resource_group_name=resource_group_name, - zone_name=azure_domain, - relative_record_set_name=relative_validation_name, - record_type='TXT' - ) + if can_delete: + client.record_sets.delete( + resource_group_name=resource_group_name, + zone_name=azure_domain, + relative_record_set_name=validation_name, + record_type='TXT' + ) + else: + client.record_sets.create_or_update( # We've manually specified a record, so dont delete, set to - + resource_group_name=resource_group_name, + zone_name=azure_domain, + relative_record_set_name=validation_name, + record_type='TXT', + parameters=RecordSet(ttl=self.ttl, txt_records=[TxtRecord(value=['-'])]) + ) except HttpResponseError as err: if err.status_code != 404: # Ignore RR not found raise errors.PluginError('Failed to remove TXT record for domain ' diff --git a/tests/integration_test.py b/tests/integration_test.py new file mode 100644 index 0000000..ac608e2 --- /dev/null +++ b/tests/integration_test.py @@ -0,0 +1,205 @@ +import os +import subprocess +import uuid +from typing import TYPE_CHECKING, List, Tuple + +import pytest +from azure.mgmt.dns import DnsManagementClient +from azure.identity import ClientSecretCredential + +if TYPE_CHECKING: + import pathlib + +AZURE_ENV = os.getenv("AZURE_ENVIRONMENT", "AzurePublicCloud") +EMAIL = os.getenv('EMAIL', 'NOT_AN_EMAIL') + +azure_creds = pytest.mark.skipif( + any(env not in os.environ for env in ['AZURE_SP_ID', 'AZURE_SP_SECRET', 'AZURE_TENANT_ID', 'EMAIL']), + reason="Missing 'AZURE_SP_ID', 'AZURE_SP_SECRET', 'AZURE_TENANT_ID' environment variables" +) + +SUBSCRIPTION_ID = '90907259-f568-40c9-be09-768317e458ae' +RESOURCE_GROUP = 'certbot' + +ZONES = { + 'zone1.certbot-dns-azure.co.uk': '/subscriptions/90907259-f568-40c9-be09-768317e458ae/resourceGroups/certbot', # /providers/Microsoft.Network/dnszones/zone1.certbot-dns-azure.co.uk + 'zone2.certbot-dns-azure.co.uk': '/subscriptions/90907259-f568-40c9-be09-768317e458ae/resourceGroups/certbot', # providers/Microsoft.Network/dnszones/zone2.certbot-dns-azure.co.uk + 'del2.certbot-dns-azure.co.uk': '/subscriptions/90907259-f568-40c9-be09-768317e458ae/resourceGroups/certbot', +} +DELEGATION_ZONE = '/subscriptions/90907259-f568-40c9-be09-768317e458ae/resourceGroups/certbot/providers/Microsoft.Network/dnsZones/del2.certbot-dns-azure.co.uk' +DELEGATION_ZONE2 = '/subscriptions/90907259-f568-40c9-be09-768317e458ae/resourceGroups/certbot/providers/Microsoft.Network/dnsZones/del1.certbot-dns-azure.co.uk/TXT/other' + + +def get_cert_names(count: int = 1) -> List[str]: + return [uuid.uuid4().hex for _ in range(count)] + + +@pytest.fixture(scope='session') +def azure_dns_client() -> DnsManagementClient: + creds = ClientSecretCredential( + client_id=os.environ['AZURE_SP_ID'], + client_secret=os.environ['AZURE_SP_SECRET'], + tenant_id=os.environ['AZURE_TENANT_ID'], + authority='https://login.microsoftonline.com/' + ) + return DnsManagementClient(creds, SUBSCRIPTION_ID, None, 'https://management.azure.com/', credential_scopes=['https://management.azure.com//.default']) + + +@pytest.fixture(scope='function', autouse=True) +def cleanup_dns(azure_dns_client): + """ + Cleans up all records in all zones defined in ZONES + + :param azure_dns_client: pytest dns client fixture + """ + yield + + for zone in ZONES: + to_delete = [] + for rr in azure_dns_client.record_sets.list_by_dns_zone(RESOURCE_GROUP, zone): + rr_type = rr.type.rsplit('/', 1)[-1] + if rr_type in ('NS', 'SOA'): + continue + + to_delete.append((rr.name, rr_type)) + for rr_name, rr_type in to_delete: + try: + azure_dns_client.record_sets.delete(RESOURCE_GROUP, zone, rr_name, rr_type) + print(f"Deleted {zone}/{rr_name}") + except Exception as err: + print(f"Tried to delete {zone}/{rr_name}, got: {err}") + + +def create_config(tmpdir: 'pathlib.Path', zones: List[str]) -> str: + """ + Creates a config file for certbot azure dns + + :param tmpdir: Temporary pytest fixture + :param zones: List of zone entries for config + :returns: Filepath to config + """ + config = { + 'dns_azure_sp_client_id': os.environ['AZURE_SP_ID'], + 'dns_azure_sp_client_secret': os.environ['AZURE_SP_SECRET'], + 'dns_azure_tenant_id': os.environ['AZURE_TENANT_ID'], + 'dns_azure_environment': AZURE_ENV, + } + for index, zone in enumerate(zones, start=1): + config[f"dns_azure_zone{index}"] = zone + + config_text = '\n'.join([' = '.join(item) for item in config.items()]) + '\n' + config_file = tmpdir / "config.ini" + config_file.write_text(config_text) + config_file.chmod(0o600) + return str(config_file) + + +def run_certbot(certbot_path: 'pathlib.Path', config_file: str, fqdns: List[str], *, dry_run: bool = False) -> Tuple[subprocess.Popen, str, str]: + args = [ + 'certbot', 'certonly', '--authenticator', 'dns-azure', '--preferred-challenges', 'dns', '--noninteractive', + '--agree-tos', + '--email', EMAIL, + '--config-dir', certbot_path, '--work-dir', certbot_path, '--logs-dir', certbot_path, + '--dns-azure-config', config_file, + ] + if dry_run: + args.append('--dry-run') + for fqdn in fqdns: + args.extend(['-d', fqdn]) + + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = proc.communicate() + if proc.returncode != 0: + print(f"Error, return code {proc.returncode}\nSTDERR:\n{stderr}\nSTDOUT:\n{stdout}") + pytest.fail() + + return proc, stdout, stderr + + +@azure_creds +def test_single_zone(tmp_path, azure_dns_client): + """ + Tests getting a certificate for a single zone + """ + certbot_path = tmp_path / "certbot" + zone = 'zone1.certbot-dns-azure.co.uk' + rr_name = get_cert_names(1)[0] + fqdn = f"{rr_name}.{zone}" + + zone_entry = f"{zone}:{ZONES[zone]}" + config_file = create_config(tmp_path, [zone_entry]) + + proc, stdout, stderr = run_certbot(certbot_path, config_file, [fqdn]) + + cert_path = certbot_path / 'archive' / fqdn / 'cert1.pem' + if not cert_path.exists(): + print(f"STDOUT:\n{stdout}") + pytest.fail(f"Certificate path {cert_path} does not exist") + + +@azure_creds +def test_multi_zone(tmp_path, azure_dns_client): + """ + Tests getting a certificate for multiple zones + """ + certbot_path = tmp_path / "certbot" + zone1 = 'zone1.certbot-dns-azure.co.uk' + zone2 = 'zone2.certbot-dns-azure.co.uk' + + rr_name1, rr_name2 = get_cert_names(2) + fqdn1 = f"{rr_name1}.{zone1}" + fqdn2 = f"{rr_name2}.{zone2}" + + zone_entry1 = f"{zone1}:{ZONES[zone1]}" + zone_entry2 = f"{zone2}:{ZONES[zone2]}" + config_file = create_config(tmp_path, [zone_entry1, zone_entry2]) + + proc, stdout, stderr = run_certbot(certbot_path, config_file, [fqdn1, fqdn2]) + + cert_path1 = certbot_path / 'archive' / fqdn1 / 'cert1.pem' + cert_path2 = certbot_path / 'archive' / fqdn2 / 'cert1.pem' + if not cert_path1.exists() and not cert_path2.exists(): + print(f"STDOUT:\n{stdout}") + pytest.fail(f"Certificate path {cert_path1} or {cert_path2} does not exist") + + +@azure_creds +def test_delegation_other_domain(tmp_path, azure_dns_client): + """ + Tests getting a certificate for a single zone + """ + certbot_path = tmp_path / "certbot" + fqdn = 'del1.certbot-dns-azure.co.uk' + + # domain is del1, but we're explicitly overriding the zone to del2 + config_file = create_config(tmp_path, [ + f"{fqdn}:{DELEGATION_ZONE}" + ]) + + proc, stdout, stderr = run_certbot(certbot_path, config_file, [fqdn]) + + cert_path = certbot_path / 'archive' / fqdn / 'cert1.pem' + if not cert_path.exists(): + print(f"STDOUT:\n{stdout}") + pytest.fail(f"Certificate path {cert_path} does not exist") + + +@azure_creds +def test_delegation_specific_record(tmp_path, azure_dns_client): + """ + Tests getting a certificate for a single zone + """ + certbot_path = tmp_path / "certbot" + fqdn = 'test.del1.certbot-dns-azure.co.uk' + + # domain is del1, but we're explicitly overriding to an alternate record of del1 + config_file = create_config(tmp_path, [ + f"{fqdn}:{DELEGATION_ZONE2}" + ]) + + proc, stdout, stderr = run_certbot(certbot_path, config_file, [fqdn]) + + cert_path = certbot_path / 'archive' / fqdn / 'cert1.pem' + if not cert_path.exists(): + print(f"STDOUT:\n{stdout}") + pytest.fail(f"Certificate path {cert_path} does not exist") \ No newline at end of file From e3f4fac7b879eac632002e0ddbaa01ff2e2d8862 Mon Sep 17 00:00:00 2001 From: Terry Cain Date: Sat, 8 Apr 2023 00:49:04 +0100 Subject: [PATCH 3/6] Updated DNS aliasing documentation --- certbot_dns_azure/__init__.py | 80 +++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/certbot_dns_azure/__init__.py b/certbot_dns_azure/__init__.py index f5733b1..6e36b9b 100644 --- a/certbot_dns_azure/__init__.py +++ b/certbot_dns_azure/__init__.py @@ -102,7 +102,7 @@ Azure Environment --------- +----------------- The Azure Cloud will default to ``AzurePublicCloud``, this is used to change the authentication endpoint used when generating credentials. This option @@ -120,30 +120,84 @@ DNS delegation -------------- + DNS delegation, also known as DNS aliasing, is a process of allowing a secondary DNS zone to handle validation in place of the primary zone. For example, you would like to acquire a certificate for ``example.com`` but have the validation performed on a secondary domain ``example.org``. You would create a ``_acme-challenge.example.com`` CNAME on the -``example.com`` nameserver with the value of ``_acme-challenge.example.com.example.org``. Certbot will resolve the CNAME and -validate the ``example.com`` domain. +``example.com`` nameserver with the value of ``_acme-challenge.example.org``. The ACME server will resolve the CNAME and +validate the TXT record ``_acme-challenge.example.org`` instead. Certbot itself does not support CNAME aliasing, +therefore this plugin does what it can to support it. The common reasons for DNS delegation are: * The primary DNS zone is hosted on a nameserver with no API access * Security concerns regarding access to the primary DNS zone -To use DNS delegation: - #. Manually create the ``_acme-challenge.`` CNAME with the value ``_acme-challenge..`` on the primary domain nameserver. - #. In the certbot azure configuration file, specify the primary domain and the entire secondary DNS zone's resource ID in ``dns_azure_zoneX``. - #. Request the certificate. +We'll use two domains for the examples below: ``foo.com`` and ``bar.com``. + +Example: Primary Zone, no API Access +++++++++++++++++++++++++++++++++++++ + +Let's assume you wish to get a certificate for ``test.foo.com``, this will result in a validation record for +``_acme-challenge.test.foo.com``. Assuming you don't have API access to the ``foo.com`` zone, you can manually CNAME said +validation record to point it to ``bar.com``. E.g. +``_acme-challenge.test.foo.com CNAME -> _acme-challenge.test.foo.com.bar.com`` This will result in a TXT record called +``_acme-challenge.test.foo.com`` created in the ``bar.com`` zone. + +This can be achieved by using the following config snippet .. code-block:: ini - :name: certbot_azure_system_msi.ini - :caption: Example configuration snippet for DNS delegation + :name: certbot_azure_alias1.ini + :caption: Example configuration snippet for DNS delegation + + dns_azure_zone1 = test.foo.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a/resourceGroups/dns1/providers/Microsoft.Network/dnszones/bar.com + +So when the Azure Certbot plugin gets requested to make ``_acme-challenge.test.foo.com``, the zone that record gets +created in is overridden to ``bar.com`` hence the entire ``_acme-challenge.test.foo.com`` is needed as the prefix in the +CNAME value before ``bar.com``. + +Example: Delegation + more security ++++++++++++++++++++++++++++++++++++ + +One can go a step further than the step above when hosting records in Azure. Instead of granting Certbot write access +to an entire DNS Zone, you can grant access to specific records. + +As with before, we shall get a certificate for ``test.foo.com``. We shall make a CNAME record like: +``_acme-challenge.test.foo.com CNAME test_validation.bar.com`` (yes it doesn't *need* ``_acme-challenge`` in this example). + +Using a config like the following: + +.. code-block:: ini + :name: certbot_azure_alias2.ini + :caption: Example configuration snippet for DNS delegation + + dns_azure_zone1 = test.foo.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a/resourceGroups/dns1/providers/Microsoft.Network/dnszones/bar.com/TXT/test_validation + +This **requires** you to create a TXT record called ``test_validation`` in the ``bar.com`` zone, the value should be ``-`` and certbot should have IAM privileges to +write to that record explicitly. Now when the request is sent to the plugin to create ``_acme-challenge.test.foo.com`` +the zone its created in will be overridden with ``bar.com`` and the validation record will be overridden with ``test_validation``. This +effectively does the same thing as if certbot had actually resolved the CNAME (which it doesn't). + +Now one caveat here, if you assign specific IAM roles to an individual record, they'll be lost when the record is cleaned up and deleted. +Which means when it comes to renewal, it'll fail as it has no IAM privileges to update said record as it no longer exists. For this reason +when the config has record ID's in it, it will not delete the validation records, it will just set its value to ``-`` +(hence you were told to set this initially). + +This example can be simplified to allow you to reduce the certbot permissions but **not** do CNAME delegation, so +for ``test.foo.com`` you would make a TXT record for ``_acme-challenge.test.foo.com`` with the value of ``-`` and then use a config +snippet like: + +.. code-block:: ini + :name: certbot_azure_moresecure.ini + :caption: Example configuration snippet for individual record permissions + + dns_azure_zone1 = test.foo.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a/resourceGroups/dns1/providers/Microsoft.Network/dnszones/foo.com/TXT/_acme-validation.test + +This will override the zone to foo.com (which ``test`` is already in) and the validation record (though it's overridden to the same thing) but now +it wont delete said validation record. - dns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a/resourceGroups/dns1/providers/Microsoft.Network/dnszones/example.org - dns_azure_zone2 = example.com:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2/providers/Microsoft.Network/dnszones/acme.example.com -Examples --------- +Generic Certbot Examples +------------------------ .. code-block:: bash :caption: To acquire a certificate for ``example.com`` From f2105b59c66f70eb53e878bb3585e5f70fe4984e Mon Sep 17 00:00:00 2001 From: Terry Cain Date: Tue, 16 May 2023 20:37:05 +0100 Subject: [PATCH 4/6] Added Azure tests --- .github/workflows/release.yml | 41 ++++++++++++++++++++-- {tests => azure_tests}/integration_test.py | 25 +++++++------ setup.py | 3 +- 3 files changed, 54 insertions(+), 15 deletions(-) rename {tests => azure_tests}/integration_test.py (89%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f04b47..d326bf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: branches: - - master + - "*" tags: - "*" @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.11 - name: Install setuptools and run: | python -m pip install --upgrade setuptools wheel @@ -24,6 +24,41 @@ jobs: - name: Test run: pytest tests/ + azure_test: + name: Azure Test + runs-on: ubuntu-latest + environment: + name: dev.azure + # Not using OIDC Auth + # permissions: + # id-token: write + # contents: read + steps: + # Not using CLI Auth + # - name: 'Az CLI Login via OIDC' + # uses: azure/login@v1 + # with: + # client-id: ${{ secrets.AZURE_CLIENT_ID }} + # tenant-id: ${{ secrets.AZURE_TENANT_ID }} + # subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Install setuptools and + run: | + python -m pip install --upgrade setuptools wheel + python -m pip install -r requirements-dev.txt + - name: Test + run: pytest azure_tests/ + env: + EMAIL: ${{ vars.EMAIL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + + publish: name: Publish if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') diff --git a/tests/integration_test.py b/azure_tests/integration_test.py similarity index 89% rename from tests/integration_test.py rename to azure_tests/integration_test.py index ac608e2..decc8bb 100644 --- a/tests/integration_test.py +++ b/azure_tests/integration_test.py @@ -5,7 +5,7 @@ import pytest from azure.mgmt.dns import DnsManagementClient -from azure.identity import ClientSecretCredential +from azure.identity import ClientSecretCredential, AzureCliCredential if TYPE_CHECKING: import pathlib @@ -14,8 +14,8 @@ EMAIL = os.getenv('EMAIL', 'NOT_AN_EMAIL') azure_creds = pytest.mark.skipif( - any(env not in os.environ for env in ['AZURE_SP_ID', 'AZURE_SP_SECRET', 'AZURE_TENANT_ID', 'EMAIL']), - reason="Missing 'AZURE_SP_ID', 'AZURE_SP_SECRET', 'AZURE_TENANT_ID' environment variables" + any(env not in os.environ for env in ['AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'EMAIL']), + reason="Missing 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID' environment variables" ) SUBSCRIPTION_ID = '90907259-f568-40c9-be09-768317e458ae' @@ -36,12 +36,15 @@ def get_cert_names(count: int = 1) -> List[str]: @pytest.fixture(scope='session') def azure_dns_client() -> DnsManagementClient: - creds = ClientSecretCredential( - client_id=os.environ['AZURE_SP_ID'], - client_secret=os.environ['AZURE_SP_SECRET'], - tenant_id=os.environ['AZURE_TENANT_ID'], - authority='https://login.microsoftonline.com/' - ) + if 'AZURE_CLIENT_SECRET' in os.environ: + creds = ClientSecretCredential( + client_id=os.environ['AZURE_CLIENT_ID'], + client_secret=os.environ['AZURE_CLIENT_SECRET'], + tenant_id=os.environ['AZURE_TENANT_ID'], + authority='https://login.microsoftonline.com/' + ) + else: + creds = AzureCliCredential(tenant_id=os.environ['AZURE_TENANT_ID']) return DnsManagementClient(creds, SUBSCRIPTION_ID, None, 'https://management.azure.com/', credential_scopes=['https://management.azure.com//.default']) @@ -79,8 +82,8 @@ def create_config(tmpdir: 'pathlib.Path', zones: List[str]) -> str: :returns: Filepath to config """ config = { - 'dns_azure_sp_client_id': os.environ['AZURE_SP_ID'], - 'dns_azure_sp_client_secret': os.environ['AZURE_SP_SECRET'], + 'dns_azure_sp_client_id': os.environ['AZURE_CLIENT_ID'], + 'dns_azure_sp_client_secret': os.environ['AZURE_CLIENT_SECRET'], 'dns_azure_tenant_id': os.environ['AZURE_TENANT_ID'], 'dns_azure_environment': AZURE_ENV, } diff --git a/setup.py b/setup.py index 81ff02a..dff6a28 100644 --- a/setup.py +++ b/setup.py @@ -39,10 +39,11 @@ 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', 'Topic :: System :: Installation/Setup', From 25f10219fe9d913a69a5dde056de7431f34c4152 Mon Sep 17 00:00:00 2001 From: Terry Cain Date: Tue, 16 May 2023 21:15:12 +0100 Subject: [PATCH 5/6] Moved to PyPI trusted publishing --- .github/workflows/release.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d326bf4..37bc061 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: python -m pip install --upgrade setuptools wheel python -m pip install -r requirements-dev.txt - name: Test - run: pytest tests/ + run: pytest -rA tests/ azure_test: name: Azure Test @@ -30,12 +30,12 @@ jobs: environment: name: dev.azure # Not using OIDC Auth - # permissions: - # id-token: write - # contents: read + #permissions: + # id-token: write + # contents: read steps: # Not using CLI Auth - # - name: 'Az CLI Login via OIDC' + #- name: 'Az CLI Login via OIDC' # uses: azure/login@v1 # with: # client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -51,32 +51,32 @@ jobs: python -m pip install --upgrade setuptools wheel python -m pip install -r requirements-dev.txt - name: Test - run: pytest azure_tests/ + run: pytest -rA azure_tests/ env: EMAIL: ${{ vars.EMAIL }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - publish: name: Publish if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') needs: test runs-on: ubuntu-latest + permissions: + id-token: write steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.11 - name: Install setuptools and run: python -m pip install --upgrade setuptools wheel - name: Build a binary wheel and a source tarball run: python setup.py build sdist bdist_wheel - name: Publish distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_TOKEN }} + #with: + # password: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file From d27ed53c9bfe8eaf94ff38d197b0343792e4eaf0 Mon Sep 17 00:00:00 2001 From: Terry Cain Date: Tue, 16 May 2023 21:16:58 +0100 Subject: [PATCH 6/6] Prep v2.2.0b0 --- setup.py | 2 +- snap/snapcraft.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index dff6a28..70e4239 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import find_packages from setuptools import setup -version = '2.1.0' +version = '2.2.0b0' # azure-mgmt-dns is still the old style SDK, so will change dramatically # when they refactor, most notably the credential parts diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 607388d..f7a8b3d 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,7 +1,7 @@ name: certbot-dns-azure summary: Azure DNS Authenticator plugin for Certbot -version: '2.1.0' +version: '2.2.0b0' description: A certbot dns plugin to obtain certificates using Azure DNS. For information on how to set up, go to the GitHub page. website: https://github.com/terrycain/certbot-dns-azure license: Apache-2.0