├── setup.cfg ├── certbot_azure ├── __init__.py ├── azure_agw_test.py ├── dns_azure_test.py ├── dns_azure.py └── azure_agw.py ├── MANIFEST.in ├── .gitignore ├── requirements.txt ├── LICENSE.txt ├── .github └── workflows │ ├── python-test.yml │ ├── python-publish.yml │ └── codeql-analysis.yml ├── setup.py └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /certbot_azure/__init__.py: -------------------------------------------------------------------------------- 1 | """Let's Encrypt Azure plugin.""" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | recursive-include docs * 4 | recursive-include certbot_azure/tests/testdata * 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | .eggs/ 4 | build/ 5 | dist*/ 6 | /venv*/ 7 | /kgs/ 8 | /.tox/ 9 | certbot.log 10 | 11 | # coverage 12 | .coverage 13 | /htmlcov/ 14 | 15 | /.vagrant 16 | 17 | # editor temporary files 18 | *~ 19 | *.swp 20 | \#*# 21 | .idea 22 | 23 | # auth --cert-path --chain-path 24 | /*.pem 25 | .env 26 | .vscode 27 | .python-version 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certbot==1.6.0 2 | adal==1.2.4 3 | azure-common==1.1.25 4 | azure-mgmt-network==11.0.0 5 | azure-mgmt-resource==10.1.0 6 | azure-mgmt-dns>=3.0.0 7 | configobj==5.0.6 8 | coverage==5.2.1 9 | cryptography==3.2 10 | decorator==4.4.2 11 | docutils==0.16 12 | entrypoints==0.3 13 | httplib2==0.18.1 14 | logger==1.4 15 | mock==4.0.2 16 | msrest==0.6.18 17 | msrestazure==0.6.4 18 | oauth2client==4.1.3 19 | oauthlib==3.1.0 20 | pathtools==0.1.2 21 | pytest==6.0.0 22 | PyOpenSSL==19.1.0 23 | zope.component==4.6.2 24 | zope.event==4.3 25 | zope.interface==5.1.0 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Diego Lapiduz 2 | 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build and test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.8 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.8 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | pytest 37 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Create release and upload python package 5 | 6 | on: 7 | push: 8 | # Sequence of patterns matched against refs/tags 9 | tags: 10 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 11 | 12 | 13 | jobs: 14 | build: 15 | name: Create Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | - name: Create Release 21 | id: create_release 22 | uses: actions/create-release@v1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 25 | with: 26 | tag_name: ${{ github.ref }} 27 | release_name: Release ${{ github.ref }} 28 | body: | 29 | Changes in this Release 30 | - First Change 31 | - Second Change 32 | draft: false 33 | prerelease: false 34 | deploy: 35 | 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Set up Python 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: '3.x' 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install setuptools wheel twine 48 | - name: Build and publish 49 | env: 50 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 51 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 52 | run: | 53 | python setup.py sdist bdist_wheel 54 | twine upload dist/* 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from distutils.core import setup 4 | from setuptools import find_packages 5 | 6 | version = '0.1.0' 7 | 8 | install_requires = [ 9 | 'acme>=0.29.0', 10 | 'certbot>=1.1.0', 11 | 'azure-mgmt-resource', 12 | 'azure-mgmt-network', 13 | 'azure-mgmt-dns>=3.0.0', 14 | 'PyOpenSSL>=19.1.0', 15 | 'setuptools', # pkg_resources 16 | 'zope.interface' 17 | ] 18 | 19 | if sys.version_info < (2, 7): 20 | install_requires.append('mock<1.1.0') 21 | else: 22 | install_requires.append('mock') 23 | 24 | docs_extras = [ 25 | 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags 26 | 'sphinx_rtd_theme', 27 | ] 28 | 29 | setup( 30 | name='certbot-azure', 31 | version=version, 32 | description="Azure plugin for Certbot client", 33 | url='https://github.com/dlapiduz/certbot-azure', 34 | author="Diego Lapiduz", 35 | author_email='diego@lapiduz.com', 36 | license='MIT', 37 | classifiers=[ 38 | 'Development Status :: 3 - Alpha', 39 | 'Environment :: Plugins', 40 | 'Intended Audience :: System Administrators', 41 | 'License :: OSI Approved :: Apache Software License', 42 | 'Operating System :: POSIX :: Linux', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.7', 46 | 'Topic :: Internet :: WWW/HTTP', 47 | 'Topic :: Security', 48 | 'Topic :: System :: Installation/Setup', 49 | 'Topic :: System :: Networking', 50 | 'Topic :: System :: Systems Administration', 51 | 'Topic :: Utilities', 52 | ], 53 | packages=find_packages(), 54 | include_package_data=True, 55 | install_requires=install_requires, 56 | keywords = ['certbot', 'azure', 'app_gateway', 'azure_dns'], 57 | entry_points={ 58 | 'certbot.plugins': [ 59 | 'azure-agw = certbot_azure.azure_agw:Installer', 60 | 'dns-azure = certbot_azure.dns_azure:Authenticator', 61 | ], 62 | }, 63 | ) 64 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 20 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['python'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build and test](https://github.com/dlapiduz/certbot-azure/workflows/Build%20and%20test/badge.svg) 2 | 3 | # Azure plugin for [Certbot](https://certbot.eff.org/) client 4 | 5 | Use the certbot client to generate and install certificates in Azure. 6 | 7 | Currently it supports authentication with Azure DNS and installation to Azure App Gateway. 8 | 9 | ### Before you start 10 | 11 | Before starting you need: 12 | 13 | - An Azure account and the Azure CLI installed. 14 | - Certbot installed locally. 15 | 16 | ### Setup 17 | 18 | The easiest way to install both the certbot client and the certbot-azure plugin is: 19 | 20 | ```bash 21 | pip install certbot-azure 22 | ``` 23 | 24 | If you are in Mac OS you will need a local set up for Python and we recommend a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). 25 | You might also need to install `dialog`: `brew install dialog`. 26 | 27 | If you are in Ubuntu you will need to install `pip` and other libraries: 28 | 29 | ```bash 30 | apt-get install python-pip python-dev libffi-dev libssl-dev libxml2-dev libxslt1-dev libjpeg8-dev zlib1g-dev dialog 31 | ``` 32 | 33 | And then run `pip install certbot-azure`. 34 | 35 | 36 | ### Obtaining a certificate with Azure DNS authentication 37 | 38 | To generate a certificate and install it in an Azure App Gateway first generate your credentials: 39 | 40 | ```bash 41 | az ad sp create-for-rbac \ 42 | --name Certbot --sdk-auth \ 43 | --role "DNS Zone Contributor" \ 44 | --scope /subscriptions//resourceGroups/ mycredentials.json 46 | ``` 47 | 48 | Then generate the certificate: 49 | 50 | ```bash 51 | certbot certonly -d REPLACE_WITH_YOUR_DOMAIN \ 52 | -a dns-azure --dns-azure-credentials mycredentials.json \ 53 | --dns-azure-resource-group 54 | ``` 55 | 56 | Follow the screen prompts and you should end up with the certificate in your 57 | distribution. It may take a couple minutes to update. 58 | 59 | 60 | ### Installing a certificate to an Azure App Gateway 61 | 62 | To generate a certificate and install it in an Azure App Gateway first generate your credentials: 63 | 64 | ```bash 65 | az ad sp create-for-rbac \ 66 | --name Certbot --sdk-auth \ 67 | --scope /subscriptions//resourceGroups/ mycredentials.json 69 | ``` 70 | 71 | Then generate and install the certificate (this example uses Azure DNS for authentication): 72 | 73 | ```bash 74 | certbot -d REPLACE_WITH_YOUR_DOMAIN \ 75 | -a dns-azure --dns-azure-credentials mycredentials.json \ 76 | --dns-azure-resource-group \ 77 | -i azure_agw --certbot-azure-ag:installer-credentials mycredentials.json \ 78 | --azure-agw-resource-group \ 79 | --azure-agw-app-gateway-name 80 | ``` 81 | 82 | Follow the screen prompts and you should end up with the certificate in your 83 | distribution. It may take a couple minutes to update. 84 | 85 | ### Automate renewal 86 | 87 | To automate the renewal process without prompts (for example, with a monthly cron), you can add the certbot parameters `--renew-by-default --text` 88 | -------------------------------------------------------------------------------- /certbot_azure/azure_agw_test.py: -------------------------------------------------------------------------------- 1 | """Tests for certbot_dns_azure.dns_azure.""" 2 | 3 | import os 4 | import unittest 5 | 6 | import mock 7 | import json 8 | 9 | from certbot import errors 10 | from certbot.plugins import dns_test_common_lexicon 11 | from certbot.plugins.dns_test_common import DOMAIN 12 | from certbot.tests import util as test_util 13 | from requests import Response 14 | 15 | from msrestazure.azure_exceptions import CloudError 16 | from azure.mgmt.network.models import ApplicationGateway 17 | from azure.mgmt.network.models import ApplicationGatewaySslCertificate 18 | 19 | 20 | RESOURCE_GROUP = 'test-test-1' 21 | 22 | class AzureClientTest(test_util.TempDirTestCase): 23 | zone = "foo.com" 24 | record_name = "bar" 25 | record_content = "baz" 26 | record_ttl = 42 27 | 28 | def _getCloudError(self): 29 | response = Response() 30 | response.status_code = 500 31 | return CloudError(response) 32 | 33 | def _generate_dummy_agw(self): 34 | agw = ApplicationGateway() 35 | agw.name = "Test" 36 | agw.ssl_certificates = [] 37 | return agw 38 | 39 | def setUp(self): 40 | from certbot_azure.azure_agw import _AzureClient 41 | super(AzureClientTest, self).setUp() 42 | 43 | config_path = AzureClientConfigDummy.build_config(self.tempdir) 44 | 45 | self.azure_client = _AzureClient(RESOURCE_GROUP, config_path) 46 | 47 | self.resource_client = mock.MagicMock() 48 | self.network_client = mock.MagicMock() 49 | self.azure_client.resource_client = self.resource_client 50 | self.azure_client.network_client = self.network_client 51 | # pylint: disable=protected-access 52 | self.azure_client._generate_pfx_from_pems = mock.MagicMock() 53 | 54 | def test_update_agw(self): 55 | agw = self._generate_dummy_agw() 56 | # pylint: disable=protected-access 57 | self.network_client.application_gateways.get.return_value = agw 58 | 59 | self.azure_client.update_agw(agw.name, 60 | "test_domain.com", 61 | "test_key_path", 62 | "test_cert_path") 63 | 64 | self.network_client.application_gateways.create_or_update.assert_called_with( 65 | self.azure_client.resource_group, 66 | agw.name, 67 | mock.ANY) 68 | 69 | updated_agw = self.network_client.application_gateways.create_or_update.call_args[0][2] 70 | 71 | self.assertEqual(len(updated_agw.ssl_certificates), 1) 72 | 73 | def test_update_agw_error(self): 74 | agw = self._generate_dummy_agw() 75 | # pylint: disable=protected-access 76 | self.network_client.application_gateways.get.return_value = agw 77 | self.network_client.application_gateways.create_or_update.side_effect = self._getCloudError() 78 | 79 | with self.assertRaises(errors.PluginError): 80 | self.azure_client.update_agw(agw.name, 81 | "test_domain.com", 82 | "test_key_path", 83 | "test_cert_path") 84 | 85 | def test_update_agw_error_if_pending(self): 86 | agw = self._generate_dummy_agw() 87 | ssl = ApplicationGatewaySslCertificate() 88 | ssl.provisioning_state = 'Updating' 89 | agw.ssl_certificates = [ssl] 90 | # pylint: disable=protected-access 91 | self.network_client.application_gateways.get.return_value = agw 92 | 93 | with self.assertRaises(errors.PluginError): 94 | self.azure_client.update_agw(agw.name, 95 | "test_domain.com", 96 | "test_key_path", 97 | "test_cert_path") 98 | 99 | 100 | class AzureClientConfigDummy(object): 101 | """Helper class to create dummy Azure configuration""" 102 | 103 | @classmethod 104 | def build_config(cls, tempdir): 105 | """Helper method to create dummy Azure configuration""" 106 | 107 | config_path = os.path.join(tempdir, 'azurecreds.json') 108 | with open(config_path, 'w') as outfile: 109 | json.dump({ 110 | "clientId": "uuid", 111 | "clientSecret": "uuid", 112 | "subscriptionId": "uuid", 113 | "tenantId": "uuid", 114 | "activeDirectoryEndpointUrl": "https://login.microsoftonline.com", 115 | "resourceManagerEndpointUrl": "https://management.azure.com/", 116 | "activeDirectoryGraphResourceId": "https://graph.windows.net/", 117 | "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/", 118 | "galleryEndpointUrl": "https://gallery.azure.com/", 119 | "managementEndpointUrl": "https://management.core.windows.net/" 120 | }, outfile) 121 | 122 | return config_path 123 | 124 | if __name__ == "__main__": 125 | unittest.main() # pragma: no cover 126 | -------------------------------------------------------------------------------- /certbot_azure/dns_azure_test.py: -------------------------------------------------------------------------------- 1 | """Tests for certbot_azure.authenticator.""" 2 | 3 | import os 4 | import unittest 5 | 6 | import mock 7 | import json 8 | 9 | from certbot import errors 10 | from certbot.plugins import dns_test_common_lexicon 11 | from certbot.plugins.dns_test_common import DOMAIN 12 | from certbot.tests import util as test_util 13 | from requests import Response 14 | 15 | from msrestazure.azure_exceptions import CloudError 16 | 17 | 18 | RESOURCE_GROUP = 'test-test-1' 19 | 20 | 21 | class AuthenticatorTest(test_util.TempDirTestCase, 22 | dns_test_common_lexicon.BaseLexiconAuthenticatorTest): 23 | 24 | def setUp(self): 25 | from certbot_azure.dns_azure import Authenticator 26 | 27 | super(AuthenticatorTest, self).setUp() 28 | 29 | config_path = AzureClientConfigDummy.build_config(self.tempdir) 30 | 31 | self.config = mock.MagicMock(azure_credentials=config_path, 32 | azure_resource_group=RESOURCE_GROUP) 33 | 34 | self.auth = Authenticator(self.config, "azure") 35 | 36 | self.mock_client = mock.MagicMock() 37 | # pylint: disable=protected-access 38 | self.auth._get_azure_client = mock.MagicMock(return_value=self.mock_client) 39 | 40 | def test_perform(self): 41 | self.auth.perform([self.achall]) 42 | 43 | expected = [mock.call.add_txt_record('_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] 44 | self.assertEqual(expected, self.mock_client.mock_calls) 45 | 46 | def test_cleanup(self): 47 | # _attempt_cleanup | pylint: disable=protected-access 48 | self.auth._attempt_cleanup = True 49 | self.auth.cleanup([self.achall]) 50 | 51 | expected = [mock.call.del_txt_record('_acme-challenge.'+DOMAIN)] 52 | self.assertEqual(expected, self.mock_client.mock_calls) 53 | 54 | 55 | class AzureClientTest(test_util.TempDirTestCase): 56 | zone = "foo.com" 57 | record_name = "bar" 58 | record_content = "baz" 59 | record_ttl = 42 60 | 61 | def _getCloudError(self): 62 | response = Response() 63 | response.status_code = 500 64 | return CloudError(response) 65 | 66 | def setUp(self): 67 | from certbot_azure.dns_azure import _AzureClient 68 | super(AzureClientTest, self).setUp() 69 | 70 | config_path = AzureClientConfigDummy.build_config(self.tempdir) 71 | 72 | self.azure_client = _AzureClient(RESOURCE_GROUP, config_path) 73 | 74 | self.dns_client = mock.MagicMock() 75 | self.azure_client.dns_client = self.dns_client 76 | # pylint: disable=protected-access 77 | self.azure_client._find_managed_zone = mock.MagicMock() 78 | 79 | def test_add_txt_record(self): 80 | # pylint: disable=protected-access 81 | self.azure_client._find_managed_zone.return_value = self.zone 82 | 83 | self.azure_client.add_txt_record(self.record_name + "." + self.zone, 84 | self.record_content, 85 | self.record_ttl) 86 | 87 | self.dns_client.record_sets.create_or_update.assert_called_with( 88 | self.azure_client.resource_group, 89 | self.zone, 90 | self.record_name, 91 | 'TXT', 92 | mock.ANY) 93 | 94 | record = self.dns_client.record_sets.create_or_update.call_args[0][4] 95 | 96 | self.assertEqual(self.record_ttl, record.ttl) 97 | self.assertEqual([self.record_content], record.txt_records[0].value) 98 | 99 | def test_add_txt_record_error(self): 100 | # pylint: disable=protected-access 101 | self.azure_client._find_managed_zone.return_value = self.zone 102 | 103 | self.dns_client.record_sets.create_or_update.side_effect = self._getCloudError() 104 | 105 | with self.assertRaises(errors.PluginError): 106 | self.azure_client.add_txt_record(self.record_name + "." + self.zone, 107 | self.record_content, 108 | self.record_ttl) 109 | 110 | def test_add_txt_record_zone_not_found(self): 111 | # pylint: disable=protected-access 112 | self.azure_client._find_managed_zone.return_value = None 113 | # pylint: disable=protected-access 114 | self.azure_client._find_managed_zone.side_effect = self._getCloudError() 115 | 116 | with self.assertRaises(errors.PluginError): 117 | self.azure_client.add_txt_record(self.record_name + "." + self.zone, 118 | self.record_content, 119 | self.record_ttl) 120 | 121 | def test_del_txt_record(self): 122 | # pylint: disable=protected-access 123 | self.azure_client._find_managed_zone.return_value = self.zone 124 | 125 | self.azure_client.del_txt_record(self.record_name + "." + self.zone) 126 | 127 | self.dns_client.record_sets.delete.assert_called_with(self.azure_client.resource_group, 128 | self.zone, 129 | self.record_name, 130 | 'TXT') 131 | def test_del_txt_record_no_zone(self): 132 | # pylint: disable=protected-access 133 | self.azure_client._find_managed_zone.return_value = None 134 | # pylint: disable=protected-access 135 | self.azure_client._find_managed_zone.side_effect = self._getCloudError() 136 | 137 | self.azure_client.del_txt_record(self.record_name + "." + self.zone) 138 | 139 | self.dns_client.record_sets.delete.assert_not_called() 140 | 141 | 142 | class AzureClientConfigDummy(object): 143 | """Helper class to create dummy Azure configuration""" 144 | 145 | @classmethod 146 | def build_config(cls, tempdir): 147 | """Helper method to create dummy Azure configuration""" 148 | 149 | config_path = os.path.join(tempdir, 'azurecreds.json') 150 | with open(config_path, 'w') as outfile: 151 | json.dump({ 152 | "clientId": "uuid", 153 | "clientSecret": "uuid", 154 | "subscriptionId": "uuid", 155 | "tenantId": "uuid", 156 | "activeDirectoryEndpointUrl": "https://login.microsoftonline.com", 157 | "resourceManagerEndpointUrl": "https://management.azure.com/", 158 | "activeDirectoryGraphResourceId": "https://graph.windows.net/", 159 | "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/", 160 | "galleryEndpointUrl": "https://gallery.azure.com/", 161 | "managementEndpointUrl": "https://management.core.windows.net/" 162 | }, outfile) 163 | 164 | os.chmod(config_path, 0o600) 165 | 166 | return config_path 167 | 168 | if __name__ == "__main__": 169 | unittest.main() # pragma: no cover 170 | -------------------------------------------------------------------------------- /certbot_azure/dns_azure.py: -------------------------------------------------------------------------------- 1 | """DNS Authenticator for Azure DNS.""" 2 | import logging 3 | import os 4 | 5 | import zope.interface 6 | 7 | from azure.mgmt.dns import DnsManagementClient 8 | from azure.common.client_factory import get_client_from_auth_file 9 | from azure.mgmt.dns.models import RecordSet, TxtRecord 10 | from msrestazure.azure_exceptions import CloudError 11 | 12 | 13 | from certbot import errors 14 | from certbot import interfaces 15 | from certbot.plugins import dns_common 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | MSDOCS = 'https://docs.microsoft.com/' 20 | ACCT_URL = MSDOCS + 'python/azure/python-sdk-azure-authenticate?view=azure-python#mgmt-auth-file' 21 | AZURE_CLI_URL = MSDOCS + 'cli/azure/install-azure-cli?view=azure-cli-latest' 22 | AZURE_CLI_COMMAND = ("az ad sp create-for-rbac" 23 | " --name Certbot --sdk-auth --role \"DNS Zone Contributor\"" 24 | " --scope /subscriptions//resourceGroups/ mycredentials.json") 26 | 27 | 28 | @zope.interface.implementer(interfaces.IAuthenticator) 29 | @zope.interface.provider(interfaces.IPluginFactory) 30 | class Authenticator(dns_common.DNSAuthenticator): 31 | """DNS Authenticator for Azure DNS 32 | 33 | This Authenticator uses the Azure DNS API to fulfill a dns-01 challenge. 34 | """ 35 | 36 | description = ( 37 | 'Obtain certificates using a DNS TXT record (if you are using Azure DNS ' 38 | 'for DNS).') 39 | ttl = 60 40 | 41 | def __init__(self, *args, **kwargs): 42 | super(Authenticator, self).__init__(*args, **kwargs) 43 | self.credentials = None 44 | 45 | @classmethod 46 | def add_parser_arguments(cls, add): # pylint: disable=arguments-differ 47 | super(Authenticator, cls).add_parser_arguments(add, 48 | default_propagation_seconds=60) 49 | add('credentials', 50 | help=( 51 | 'Path to Azure DNS service account JSON file. If you already have a Service ' + 52 | 'Principal with the required permissions, you can create your own file as per ' + 53 | 'the JSON file format at {0}. ' + 54 | 'Otherwise, you can create a new Service Principal using the Azure CLI ' + 55 | '(available at {1}) by running "az login" then "{2}"' + 56 | 'This will create file "mycredentials.json" which you should secure, then ' + 57 | 'specify with this option or with the AZURE_AUTH_LOCATION environment variable.') 58 | .format(ACCT_URL, AZURE_CLI_URL, AZURE_CLI_COMMAND), 59 | default=None) 60 | add('resource-group', 61 | help=('Resource Group in which the DNS zone is located'), 62 | default=None) 63 | 64 | def more_info(self): # pylint: disable=missing-docstring,no-self-use 65 | return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 66 | 'the Azure DNS API.' 67 | 68 | def _setup_credentials(self): 69 | if self.conf('resource-group') is None: 70 | raise errors.PluginError('Please specify a resource group using ' 71 | '--dns-azure-resource-group ') 72 | 73 | if self.conf( 74 | 'credentials') is None and 'AZURE_AUTH_LOCATION' not in os.environ: 75 | raise errors.PluginError( 76 | 'Please specify credentials file using the ' 77 | 'AZURE_AUTH_LOCATION environment variable or ' 78 | 'using --dns-azure-credentials ') 79 | else: 80 | self._configure_file('credentials', 81 | 'path to Azure DNS service account JSON file') 82 | 83 | dns_common.validate_file_permissions(self.conf('credentials')) 84 | 85 | def _perform(self, domain, validation_name, validation): 86 | self._get_azure_client().add_txt_record(validation_name, 87 | validation, 88 | self.ttl) 89 | 90 | def _cleanup(self, domain, validation_name, validation): 91 | self._get_azure_client().del_txt_record(validation_name) 92 | 93 | def _get_azure_client(self): 94 | return _AzureClient(self.conf('resource-group'), 95 | self.conf('credentials')) 96 | 97 | 98 | class _AzureClient(object): 99 | """ 100 | Encapsulates all communication with the Azure Cloud DNS API. 101 | """ 102 | 103 | def __init__(self, resource_group, account_json=None): 104 | self.resource_group = resource_group 105 | self.dns_client = get_client_from_auth_file(DnsManagementClient, 106 | auth_path=account_json) 107 | 108 | def add_txt_record(self, domain, record_content, record_ttl): 109 | """ 110 | Add a TXT record using the supplied information. 111 | 112 | :param str domain: The fqdn (typically beginning with '_acme-challenge.'). 113 | :param str record_content: The record content (typically the challenge validation). 114 | :param int record_ttl: The record TTL (number of seconds that the record may be cached). 115 | :raises certbot.errors.PluginError: if an error occurs communicating with the Azure API 116 | """ 117 | try: 118 | record = RecordSet(ttl=record_ttl, 119 | txt_records=[TxtRecord(value=[record_content])]) 120 | zone = self._find_managed_zone(domain) 121 | relative_record_name = ".".join( 122 | domain.split('.')[0:-len(zone.split('.'))]) 123 | self.dns_client.record_sets.create_or_update(self.resource_group, 124 | zone, 125 | relative_record_name, 126 | 'TXT', 127 | record) 128 | except CloudError as e: 129 | logger.error('Encountered error adding TXT record: %s', e) 130 | raise errors.PluginError('Error communicating with the Azure DNS API: {0}'.format(e)) 131 | 132 | def del_txt_record(self, domain): 133 | """ 134 | Delete a TXT record using the supplied information. 135 | 136 | :param str domain: The fqdn (typically beginning with '_acme-challenge.'). 137 | :raises certbot.errors.PluginError: if an error occurs communicating with the Azure API 138 | """ 139 | 140 | try: 141 | zone = self._find_managed_zone(domain) 142 | relative_record_name = ".".join( 143 | domain.split('.')[0:-len(zone.split('.'))]) 144 | self.dns_client.record_sets.delete(self.resource_group, 145 | zone, 146 | relative_record_name, 147 | 'TXT') 148 | except (CloudError, errors.PluginError) as e: 149 | logger.warning('Encountered error deleting TXT record: %s', e) 150 | 151 | def _find_managed_zone(self, domain): 152 | """ 153 | Find the managed zone for a given domain. 154 | 155 | :param str domain: The domain for which to find the managed zone. 156 | :returns: The name of the managed zone, if found. 157 | :rtype: str 158 | :raises certbot.errors.PluginError: if the managed zone cannot be found. 159 | """ 160 | try: 161 | azure_zones = self.dns_client.zones.list() # TODO - catch errors 162 | azure_zones_list = [] 163 | while True: 164 | for zone in azure_zones.current_page: 165 | azure_zones_list.append(zone.name) 166 | azure_zones.next() 167 | except StopIteration: 168 | pass 169 | except CloudError as e: 170 | logger.error('Error finding zone: %s', e) 171 | raise errors.PluginError('Error finding zone form the Azure DNS API: {0}'.format(e)) 172 | zone_dns_name_guesses = dns_common.base_domain_name_guesses(domain) 173 | 174 | for zone_name in zone_dns_name_guesses: 175 | if zone_name in azure_zones_list: 176 | return zone_name 177 | 178 | raise errors.PluginError( 179 | 'Unable to determine managed zone for {0} using zone names: {1}.' 180 | .format(domain, zone_dns_name_guesses)) 181 | -------------------------------------------------------------------------------- /certbot_azure/azure_agw.py: -------------------------------------------------------------------------------- 1 | """Azure App Gateway Certbot installer plugin.""" 2 | 3 | from __future__ import print_function 4 | 5 | import os 6 | import sys 7 | import logging 8 | import time 9 | import OpenSSL 10 | import base64 11 | try: 12 | from secrets import token_urlsafe 13 | except ImportError: 14 | from os import urandom 15 | def token_urlsafe(nbytes=None): 16 | return urandom(nbytes) 17 | 18 | import zope.component 19 | import zope.interface 20 | 21 | from certbot import interfaces 22 | from certbot import errors 23 | 24 | from certbot.plugins import common 25 | 26 | from azure.common.client_factory import get_client_from_auth_file 27 | from azure.mgmt.resource import ResourceManagementClient 28 | from azure.mgmt.network import NetworkManagementClient 29 | from msrestazure.azure_exceptions import CloudError 30 | 31 | 32 | MSDOCS = 'https://docs.microsoft.com/' 33 | ACCT_URL = MSDOCS + 'python/azure/python-sdk-azure-authenticate?view=azure-python#mgmt-auth-file' 34 | AZURE_CLI_URL = MSDOCS + 'cli/azure/install-azure-cli?view=azure-cli-latest' 35 | AZURE_CLI_COMMAND = ("az ad sp create-for-rbac" 36 | " --name Certbot --sdk-auth" 37 | " --scope /subscriptions//resourceGroups/" 38 | " > mycredentials.json") 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | @zope.interface.implementer(interfaces.IInstaller) 43 | @zope.interface.provider(interfaces.IPluginFactory) 44 | class Installer(common.Plugin): 45 | 46 | description = "Certbot Azure Installer" 47 | 48 | @classmethod 49 | def add_parser_arguments(cls, add): 50 | add('credentials', 51 | help=( 52 | 'Path to Azure service account JSON file. If you already have a Service ' + 53 | 'Principal with the required permissions, you can create your own file as per ' + 54 | 'the JSON file format at {0}. ' + 55 | 'Otherwise, you can create a new Service Principal using the Azure CLI ' + 56 | '(available at {1}) by running "az login" then "{2}"' + 57 | 'This will create file "mycredentials.json" which you should secure, then ' + 58 | 'specify with this option or with the AZURE_AUTH_LOCATION environment variable.') 59 | .format(ACCT_URL, AZURE_CLI_URL, AZURE_CLI_COMMAND), 60 | default=None) 61 | add('resource-group', 62 | help=('Resource Group in which the DNS zone is located'), 63 | default=None) 64 | add('app-gateway-name', 65 | help=('Name of the application gateway'), 66 | default=None) 67 | 68 | def __init__(self, *args, **kwargs): 69 | super(Installer, self).__init__(*args, **kwargs) 70 | self._setup_credentials() 71 | 72 | self.azure_client = _AzureClient(self.conf('resource-group'), self.conf('credentials')) 73 | 74 | 75 | def _setup_credentials(self): 76 | if self.conf('resource-group') is None: 77 | raise errors.PluginError('Please specify a resource group using ' 78 | '--azure-agw-resource-group ') 79 | 80 | if self.conf('app-gateway-name') is None: 81 | raise errors.PluginError('Please specify the app gateway name ' 82 | '--azure-agw-resource-group ') 83 | 84 | if self.conf( 85 | 'credentials') is None and 'AZURE_AUTH_LOCATION' not in os.environ: 86 | raise errors.PluginError( 87 | 'Please specify credentials file using the ' 88 | 'AZURE_AUTH_LOCATION environment variable or ' 89 | 'using --azure-agw-credentials ') 90 | 91 | def prepare(self): # pylint: disable=missing-docstring,no-self-use 92 | pass # pragma: no cover 93 | 94 | def more_info(self): # pylint: disable=missing-docstring,no-self-use 95 | return ("") 96 | 97 | def get_all_names(self): # pylint: disable=missing-docstring,no-self-use 98 | pass # pragma: no cover 99 | 100 | def deploy_cert(self, domain, cert_path, key_path, chain_path, fullchain_path): 101 | """ 102 | Upload Certificate to the app gateway 103 | """ 104 | 105 | self.azure_client.update_agw(self.conf('app-gateway-name'),domain, key_path, fullchain_path) 106 | 107 | def enhance(self, domain, enhancement, options=None): # pylint: disable=missing-docstring,no-self-use 108 | pass # pragma: no cover 109 | 110 | def supported_enhancements(self): # pylint: disable=missing-docstring,no-self-use 111 | return [] # pragma: no cover 112 | 113 | def get_all_certs_keys(self): # pylint: disable=missing-docstring,no-self-use 114 | pass # pragma: no cover 115 | 116 | def save(self, title=None, temporary=False): # pylint: disable=missing-docstring,no-self-use 117 | pass # pragma: no cover 118 | 119 | def rollback_checkpoints(self, rollback=1): # pylint: disable=missing-docstring,no-self-use 120 | pass # pragma: no cover 121 | 122 | def recovery_routine(self): # pylint: disable=missing-docstring,no-self-use 123 | pass # pragma: no cover 124 | 125 | def view_config_changes(self): # pylint: disable=missing-docstring,no-self-use 126 | pass # pragma: no cover 127 | 128 | def config_test(self): # pylint: disable=missing-docstring,no-self-use 129 | pass # pragma: no cover 130 | 131 | def restart(self): # pylint: disable=missing-docstring,no-self-use 132 | pass # pragma: no cover 133 | 134 | def renew_deploy(self, lineage, *args, **kwargs): # pylint: disable=missing-docstring,no-self-use 135 | """ 136 | Renew certificates when calling `certbot renew` 137 | """ 138 | 139 | # Run deploy_cert with the lineage params 140 | self.deploy_cert(lineage.names()[0], lineage.cert_path, lineage.key_path, lineage.chain_path, lineage.fullchain_path) 141 | 142 | return 143 | 144 | 145 | 146 | class _AzureClient(object): 147 | """ 148 | Encapsulates all communication with the Azure Cloud DNS API. 149 | """ 150 | 151 | def __init__(self, resource_group, account_json=None): 152 | self.resource_group = resource_group 153 | self.resource_client = get_client_from_auth_file(ResourceManagementClient, 154 | auth_path=account_json) 155 | self.network_client = get_client_from_auth_file(NetworkManagementClient, 156 | auth_path=account_json) 157 | 158 | 159 | 160 | def update_agw(self, agw_name, domain, key_path, fullchain_path): 161 | from azure.mgmt.network.models import ApplicationGatewaySslCertificate 162 | 163 | # Generate random password for pfx 164 | password = token_urlsafe(16) 165 | 166 | # Get app gateway from client 167 | agw = self.network_client.application_gateways.get(self.resource_group, agw_name) 168 | 169 | if "Updating" in [ssl.provisioning_state for ssl in agw.ssl_certificates]: 170 | raise errors.PluginError('There is a certificate in Updating state. Cowardly refusing to add a new one.') 171 | 172 | ssl = ApplicationGatewaySslCertificate() 173 | ssl.name = domain + str(int(time.time())) 174 | ssl.data = self._generate_pfx_from_pems(key_path, fullchain_path, password) 175 | ssl.password = password 176 | 177 | agw.ssl_certificates.append(ssl) 178 | 179 | try: 180 | self.network_client.application_gateways.create_or_update(self.resource_group, 181 | agw_name, 182 | agw) 183 | except CloudError as e: 184 | logger.warning('Encountered error updating app gateway: %s', e) 185 | raise errors.PluginError('Error communicating with the Azure API: {0}'.format(e)) 186 | 187 | def _generate_pfx_from_pems(self, key_path, fullchain_path, password): 188 | """Generate PFX file out of PEMs in order to meet App Gateway requirements""" 189 | 190 | from cryptography.hazmat.backends import default_backend 191 | from cryptography.hazmat.primitives import serialization 192 | from cryptography import x509 193 | 194 | p12 = OpenSSL.crypto.PKCS12() 195 | 196 | # Load Key into PKCS12 object 197 | with open(key_path, "rb") as key_file: 198 | private_key = serialization.load_pem_private_key( 199 | key_file.read(), 200 | password=None, 201 | backend=default_backend() 202 | ) 203 | 204 | key = OpenSSL.crypto.PKey.from_cryptography_key(private_key) 205 | p12.set_privatekey(key) 206 | 207 | # Load Cert into PKCS12 object 208 | with open(fullchain_path, "rb") as cert_file: 209 | crypto_cert = x509.load_pem_x509_certificate( 210 | cert_file.read(), 211 | default_backend()) 212 | 213 | cert = OpenSSL.crypto.X509.from_cryptography(crypto_cert) 214 | p12.set_certificate(cert) 215 | 216 | # Export object 217 | data = p12.export(passphrase=password) 218 | 219 | # Return base64 encoded string 220 | return str(base64.b64encode(data), "utf-8") 221 | --------------------------------------------------------------------------------