From e93f203521a36042471449dd4894fa2784fca2dc Mon Sep 17 00:00:00 2001 From: Dmitry Volodin Date: Fri, 17 Nov 2023 13:21:30 +0100 Subject: [PATCH] WebACMEClient --- README.md | 3 +- docs/index.md | 3 +- src/gufo/acme/clients/__init__.py | 1 + src/gufo/acme/clients/dav.py | 4 +- src/gufo/acme/clients/web.py | 95 +++++++++++++++++++++++++++++++ tests/clients/test_base.py | 16 +----- tests/clients/test_web.py | 38 +++++++++++++ tests/clients/utils.py | 14 +++++ 8 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 src/gufo/acme/clients/web.py create mode 100644 tests/clients/test_web.py diff --git a/README.md b/README.md index b06164a..95fa86d 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ simplifies the protocol complexity with a straightforward and robust API. Gufo ACME contains various clients which can be applied to your tasks: -* [ACMEClient]() - base client to implement any fulfillment functionality +* ACMEClient - base client to implement any fulfillment functionality by creating subclasses. * DAVACMEClient - http-01 fulfillment using WebDAV methods. +* WebACMEClient - http-01 static file fulfillment. ## Supported Certificate Authorities diff --git a/docs/index.md b/docs/index.md index 285166e..899e11b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,8 @@ Gufo ACME contains various clients which can be applied to your tasks: * [ACMEClient][gufo.acme.clients.base.ACMEClient] - base client to implement any fulfillment functionality by creating subclasses. -* [DAVACMEClient][gufo.acme.clients.dav.DAVACMEClient] - http-01 fulfillment using WebDAV methods. +* [DAVACMEClient][gufo.acme.clients.dav.DAVACMEClient] - http-01 fulfillment using WebDAV methods. +* [WebACMEClient][gufo.acme.clients.web.WebACMEClient] - http-01 static file fulfillment. ## Supported Certificate Authorities diff --git a/src/gufo/acme/clients/__init__.py b/src/gufo/acme/clients/__init__.py index aa08972..3026983 100644 --- a/src/gufo/acme/clients/__init__.py +++ b/src/gufo/acme/clients/__init__.py @@ -11,4 +11,5 @@ * [base][gufo.acme.clients.base] - Base class * [dav][gufo.acme.clients.dav] - http-01 WebDAV fulfillment. +* [web][gufo.acme.clients.web] - http-01 static file fulfillment. """ diff --git a/src/gufo/acme/clients/dav.py b/src/gufo/acme/clients/dav.py index 580c7d0..d524291 100644 --- a/src/gufo/acme/clients/dav.py +++ b/src/gufo/acme/clients/dav.py @@ -1,9 +1,9 @@ # --------------------------------------------------------------------- -# Gufo ACME: ACMEClient implementation +# Gufo ACME: DAVACMEClient implementation # --------------------------------------------------------------------- # Copyright (C) 2023, Gufo Labs # --------------------------------------------------------------------- -"""An DAVACMEClient implementation.""" +"""A DAVACMEClient implementation.""" # Python modules from typing import Any diff --git a/src/gufo/acme/clients/web.py b/src/gufo/acme/clients/web.py new file mode 100644 index 0000000..df82e07 --- /dev/null +++ b/src/gufo/acme/clients/web.py @@ -0,0 +1,95 @@ +# --------------------------------------------------------------------- +# Gufo ACME: WebACMEClient implementation +# --------------------------------------------------------------------- +# Copyright (C) 2023, Gufo Labs +# --------------------------------------------------------------------- +"""A WebACMEClient implementation.""" + +# Python modules +import os +from pathlib import Path +from typing import Any, Union + +# Gufo ACME modules +from ..log import logger +from ..types import ACMEChallenge +from .base import ACMEClient + + +class WebACMEClient(ACMEClient): + """ + A webserver-backed ACME client. + + Fulfills http-01 challenge by creating + and removing token files in predefined + directories. + + Args: + path: Path mapped to /.well-known/acme-challenges + directory. + """ + + def __init__( + self: "WebACMEClient", + directory_url: str, + *, + path: Union[str, Path], + **kwargs: Any, + ) -> None: + super().__init__(directory_url, **kwargs) + self.path = Path(path) + + def _get_path(self: "WebACMEClient", challenge: ACMEChallenge) -> Path: + """ + Get Path for challenge. + + Args: + challenge: ACME challenge + + Returns: + token path. + """ + return self.path / Path(challenge.token) + + async def fulfill_http_01( + self: "WebACMEClient", domain: str, challenge: ACMEChallenge + ) -> bool: + """ + Perform http-01 fullfilment. + + Put token to / file. + + Args: + domain: Domain name + challenge: ACMEChallenge instance, containing token. + + Returns: + True - on succeess + + Raises: + ACMEFulfillmentFailed: On error. + """ + path = self._get_path(challenge) + v = self.get_key_authorization(challenge) + logger.warning("Writing token to %s", path) + with open(path, "wb") as fp: + fp.write(v) + return True + + async def clear_http_01( + self: "WebACMEClient", domain: str, challenge: ACMEChallenge + ) -> None: + """ + Remove provisioned token. + + Args: + domain: Domain name + challenge: ACMEChallenge instance, containing token. + + Raises: + ACMEFulfillmentFailed: On error. + """ + path = self._get_path(challenge) + if os.path.exists(path): + logger.warning("Removing token from %s", path) + os.unlink(path) diff --git a/tests/clients/test_base.py b/tests/clients/test_base.py index b35ecab..8e438e5 100644 --- a/tests/clients/test_base.py +++ b/tests/clients/test_base.py @@ -35,24 +35,10 @@ from httpx import ConnectError, Response from josepy.jwk import JWKRSA -from .utils import DIRECTORY, EMAIL, get_csr_pem, not_set, not_set_reason +from .utils import DIRECTORY, EMAIL, KEY, get_csr_pem, not_set, not_set_reason ENV_CI_ACME_TEST_DOMAIN = "CI_ACME_TEST_DOMAIN" -KEY = JWKRSA.from_json( - { - "n": "gvvjoJPd1L4sq1bT0q2C94N3WV7W7lroA_MzF-SGMVYFasI2lvqw3kAkFRxG366JfHr3B1R-xlCzEPHNixbL6b0ccvPFZZsungnx5m_uGL2FMiisu186dMnfsk6YssveboxiQXEhGMxI9T6GjE6l6ec1PGY5uB70vP2wkGPxkvRLD2tGae_-7kCgRzvF2xOaGZjT-jxHcYpWutNN-qQzDoHnhLu0LIwWlXBazAs6zbkPvPW9PNZAUencWxxQ5hJtLkVSvgSYwzI1cxlrC8lCjg6rIR9LA8s5PLzee_nEotljlU0ljXz3eyD9W4fl4rC46v8-ufk5Ez9utQQ2sVjIMQ", - "e": "AQAB", - "d": "AUosSQ5trbCn8VG1_R4D4y6oZiERwBf1bwUF9rUziFC01dLW3WSXaV_TryDHtqACBu-Rx0Di7O5aXgdIfycsv7bizOO3OM927XvS9cI6Q6R5l1do0IFA-smKVifRl3icDoX7uXHdCeDIkuAgTGlBl1iVSMyHotdMsP_1PS27wSb6q0miPLJzZFPcMz2WcRaPaVFjsg_l9J8_Sy6d0HWx7_2nrxvOESlUNwf7sRn2WY2ZQaGwBzS6L17aHeKkQiAgUMAJK6gF3SGLK8kiHJac-p5bxFMu38fFY3FcVCW-QJMhRZMHaV36XZliIfP6DLFBzHqj99iZqgR8LvQ1SeMLgQ", - "p": "t8GsAuPj1WujpvId3eJwhPUsbxJuIcd0Zi6hLTlQvydOI4jUtfO9JzHvEFG9GSZtedaJ5Vga0OFQlpCyhNHLQe6JjJnriexazHK-dLlUr6cVmaCNSj9spa7azZF8ak8EtISAr-7zdzLGy2KiC-DsJwp36RlSOyD6APkDCthSoIE", - "q": "tnrgWqyT2alj9gtjgWb-xY07qxFGBSBZEO6dY5hJsydbkLRGumX5mvhqKy3BBbROz1smVNlMxcJa8fEWwQn8EORmr9-86i6TQUyZyCRMbh1pO63D4mJGjuhCHDgKyzzxYczeBPI2MxFVnZHPUAjWJwUGZp3sMEGzwej5g0iwT7E", - "dp": "jU8UVkylwlO6UAHU0fL2kGhyOSA1LSjS7FljfQGchMNXJaBt41aC2Ydezm_tOVAB1DYVaRbt2D_M11yCy_0Bj7w-bq9XIINv99UtfVmgNEwLIk8DGFvZ0ze572e4A5Csj51t0N2ywLF9ip5Y-0WGlSdJuynLwMjFOMZFfquILwE", - "dq": "Ck1dpUDhCATcM-PotkGOWLDkkX_kKB3vaVlPYXQTlR2_uaez5oojUXB87fsjTqMjX-mRfHDYOMIESGyIEFXz-TAr6_oBvGbswV8Fv5rtBbp7Wncw-_L4cNEECnvPgDHsnszmK_lQvglYgBDfV3FoRcOu3NRFpWPQNj5k99h-u8E", - "qi": "Z3Ipo4AnRJfszwEEb2Y-mZgkgrZJguoixPleH-QSmy9vJ17-9URMv62MWKv19X5HdluxZJYmKGSLbbuMWD9-MVntVFSb77YKNrE2kCGM8a--aWtv706dHUSZemRazib55HtcGn2H6D3laUigFSmPNCdfq8CjsWLeW8RVOyY5tgM", - "kty": "RSA", - } -) - def test_get_directory() -> None: async def inner(): diff --git a/tests/clients/test_web.py b/tests/clients/test_web.py new file mode 100644 index 0000000..e416aa0 --- /dev/null +++ b/tests/clients/test_web.py @@ -0,0 +1,38 @@ +# Python modules +import asyncio +import os +import tempfile +from pathlib import Path + +# Gufo ACMEE modules +from gufo.acme.clients.web import WebACMEClient +from gufo.acme.types import ACMEChallenge + +from .utils import DIRECTORY, KEY + +DOMAIN = "example.com" +AUTH_KEY = ( + b"qPsDhTbvumL6q2DryepIyzJS1nMkghS92IqI4mug-7c.IoRaMHpsTJikEnDg70XhS0d7Xx1ZU3Tz" + b"MzhTKFITfwY" +) + + +def test_web() -> None: + async def inner(): + chall = ACMEChallenge( + type="http-01", + url="xxx", + token="qPsDhTbvumL6q2DryepIyzJS1nMkghS92IqI4mug-7c", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) + client = WebACMEClient(DIRECTORY, key=KEY, path=path) + r = await client.fulfill_http_01(DOMAIN, chall) + assert r is True + with open(path / chall.token, "rb") as fp: + data = fp.read() + assert data == AUTH_KEY + await client.clear_http_01(DOMAIN, chall) + assert not os.path.exists(path / Path(chall.token)) + + asyncio.run(inner()) diff --git a/tests/clients/utils.py b/tests/clients/utils.py index b28217a..b8ee6d5 100644 --- a/tests/clients/utils.py +++ b/tests/clients/utils.py @@ -10,9 +10,23 @@ # Gufo ACME modules from gufo.acme.clients.base import ACMEClient +from josepy.jwk import JWKRSA EMAIL = "acme-000000000@gufolabs.com" DIRECTORY = "https://acme-staging-v02.api.letsencrypt.org/directory" +KEY = JWKRSA.from_json( + { + "n": "gvvjoJPd1L4sq1bT0q2C94N3WV7W7lroA_MzF-SGMVYFasI2lvqw3kAkFRxG366JfHr3B1R-xlCzEPHNixbL6b0ccvPFZZsungnx5m_uGL2FMiisu186dMnfsk6YssveboxiQXEhGMxI9T6GjE6l6ec1PGY5uB70vP2wkGPxkvRLD2tGae_-7kCgRzvF2xOaGZjT-jxHcYpWutNN-qQzDoHnhLu0LIwWlXBazAs6zbkPvPW9PNZAUencWxxQ5hJtLkVSvgSYwzI1cxlrC8lCjg6rIR9LA8s5PLzee_nEotljlU0ljXz3eyD9W4fl4rC46v8-ufk5Ez9utQQ2sVjIMQ", + "e": "AQAB", + "d": "AUosSQ5trbCn8VG1_R4D4y6oZiERwBf1bwUF9rUziFC01dLW3WSXaV_TryDHtqACBu-Rx0Di7O5aXgdIfycsv7bizOO3OM927XvS9cI6Q6R5l1do0IFA-smKVifRl3icDoX7uXHdCeDIkuAgTGlBl1iVSMyHotdMsP_1PS27wSb6q0miPLJzZFPcMz2WcRaPaVFjsg_l9J8_Sy6d0HWx7_2nrxvOESlUNwf7sRn2WY2ZQaGwBzS6L17aHeKkQiAgUMAJK6gF3SGLK8kiHJac-p5bxFMu38fFY3FcVCW-QJMhRZMHaV36XZliIfP6DLFBzHqj99iZqgR8LvQ1SeMLgQ", + "p": "t8GsAuPj1WujpvId3eJwhPUsbxJuIcd0Zi6hLTlQvydOI4jUtfO9JzHvEFG9GSZtedaJ5Vga0OFQlpCyhNHLQe6JjJnriexazHK-dLlUr6cVmaCNSj9spa7azZF8ak8EtISAr-7zdzLGy2KiC-DsJwp36RlSOyD6APkDCthSoIE", + "q": "tnrgWqyT2alj9gtjgWb-xY07qxFGBSBZEO6dY5hJsydbkLRGumX5mvhqKy3BBbROz1smVNlMxcJa8fEWwQn8EORmr9-86i6TQUyZyCRMbh1pO63D4mJGjuhCHDgKyzzxYczeBPI2MxFVnZHPUAjWJwUGZp3sMEGzwej5g0iwT7E", + "dp": "jU8UVkylwlO6UAHU0fL2kGhyOSA1LSjS7FljfQGchMNXJaBt41aC2Ydezm_tOVAB1DYVaRbt2D_M11yCy_0Bj7w-bq9XIINv99UtfVmgNEwLIk8DGFvZ0ze572e4A5Csj51t0N2ywLF9ip5Y-0WGlSdJuynLwMjFOMZFfquILwE", + "dq": "Ck1dpUDhCATcM-PotkGOWLDkkX_kKB3vaVlPYXQTlR2_uaez5oojUXB87fsjTqMjX-mRfHDYOMIESGyIEFXz-TAr6_oBvGbswV8Fv5rtBbp7Wncw-_L4cNEECnvPgDHsnszmK_lQvglYgBDfV3FoRcOu3NRFpWPQNj5k99h-u8E", + "qi": "Z3Ipo4AnRJfszwEEb2Y-mZgkgrZJguoixPleH-QSmy9vJ17-9URMv62MWKv19X5HdluxZJYmKGSLbbuMWD9-MVntVFSb77YKNrE2kCGM8a--aWtv706dHUSZemRazib55HtcGn2H6D3laUigFSmPNCdfq8CjsWLeW8RVOyY5tgM", + "kty": "RSA", + } +) def not_set(vars: Iterable[str]) -> bool: