From 924410d14785935cc48e0fd5af7df9d01474bd29 Mon Sep 17 00:00:00 2001 From: Logan Cary Date: Wed, 1 Oct 2025 14:25:27 -0400 Subject: [PATCH 01/12] return generated csr as bytes --- truenas_crypto_utils/csr.py | 4 ++-- truenas_crypto_utils/read.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/truenas_crypto_utils/csr.py b/truenas_crypto_utils/csr.py index bf5da9f..d620bce 100644 --- a/truenas_crypto_utils/csr.py +++ b/truenas_crypto_utils/csr.py @@ -7,7 +7,7 @@ from .utils import CERT_BACKEND_MAPPINGS, EC_CURVE_DEFAULT -def generate_certificate_signing_request(data: dict) -> tuple[str, str]: +def generate_certificate_signing_request(data: dict) -> tuple[bytes, str]: key = generate_private_key({ 'type': data.get('key_type') or 'RSA', 'curve': data.get('ec_curve') or EC_CURVE_DEFAULT, @@ -27,4 +27,4 @@ def generate_certificate_signing_request(data: dict) -> tuple[str, str]: csr = add_extensions(csr, data.get('cert_extensions', {}), key, None) csr = csr.sign(key, retrieve_signing_algorithm(data, key), default_backend()) - return csr.public_bytes(serialization.Encoding.PEM).decode(), export_private_key_object(key) + return csr.public_bytes(serialization.Encoding.PEM), export_private_key_object(key) diff --git a/truenas_crypto_utils/read.py b/truenas_crypto_utils/read.py index 24fc650..6eaec8d 100644 --- a/truenas_crypto_utils/read.py +++ b/truenas_crypto_utils/read.py @@ -131,7 +131,7 @@ def parse_name_components(obj: crypto.X509Name) -> str: return f'/{"/".join(dn)}' -def load_certificate_request(csr: str) -> dict: +def load_certificate_request(csr: bytes) -> dict: try: csr_obj = crypto.load_certificate_request(crypto.FILETYPE_PEM, csr.encode()) except crypto.Error: From 540bcfd09e27af94b8db76a3b0b67f0fd8e9c552 Mon Sep 17 00:00:00 2001 From: Logan Cary Date: Mon, 6 Oct 2025 01:17:59 -0400 Subject: [PATCH 02/12] maintain string returns --- truenas_crypto_utils/csr.py | 4 ++-- truenas_crypto_utils/read.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/truenas_crypto_utils/csr.py b/truenas_crypto_utils/csr.py index d620bce..bf5da9f 100644 --- a/truenas_crypto_utils/csr.py +++ b/truenas_crypto_utils/csr.py @@ -7,7 +7,7 @@ from .utils import CERT_BACKEND_MAPPINGS, EC_CURVE_DEFAULT -def generate_certificate_signing_request(data: dict) -> tuple[bytes, str]: +def generate_certificate_signing_request(data: dict) -> tuple[str, str]: key = generate_private_key({ 'type': data.get('key_type') or 'RSA', 'curve': data.get('ec_curve') or EC_CURVE_DEFAULT, @@ -27,4 +27,4 @@ def generate_certificate_signing_request(data: dict) -> tuple[bytes, str]: csr = add_extensions(csr, data.get('cert_extensions', {}), key, None) csr = csr.sign(key, retrieve_signing_algorithm(data, key), default_backend()) - return csr.public_bytes(serialization.Encoding.PEM), export_private_key_object(key) + return csr.public_bytes(serialization.Encoding.PEM).decode(), export_private_key_object(key) diff --git a/truenas_crypto_utils/read.py b/truenas_crypto_utils/read.py index 6eaec8d..24fc650 100644 --- a/truenas_crypto_utils/read.py +++ b/truenas_crypto_utils/read.py @@ -131,7 +131,7 @@ def parse_name_components(obj: crypto.X509Name) -> str: return f'/{"/".join(dn)}' -def load_certificate_request(csr: bytes) -> dict: +def load_certificate_request(csr: str) -> dict: try: csr_obj = crypto.load_certificate_request(crypto.FILETYPE_PEM, csr.encode()) except crypto.Error: From 32a79ef0bd4b5c1a9ed304e382dc07822b964807 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 11:04:31 +0500 Subject: [PATCH 03/12] Add renewal info to acme client if present --- truenas_acme_utils/client_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/truenas_acme_utils/client_utils.py b/truenas_acme_utils/client_utils.py index da4d0e7..1ea4157 100644 --- a/truenas_acme_utils/client_utils.py +++ b/truenas_acme_utils/client_utils.py @@ -17,6 +17,7 @@ class ACMEClientAndKeyData(typing.TypedDict): new_nonce_uri: str new_order_uri: str revoke_cert_uri: str + renewal_info: str | None body: BodyDict @@ -29,6 +30,7 @@ def get_acme_client_and_key(data: ACMEClientAndKeyData) -> tuple[client.ClientV2 - new_nonce_uri: str - new_order_uri: str - revoke_cert_uri: str + - renewal_info: str (optional) - body: dict - status: str - key: dict @@ -58,7 +60,8 @@ def get_acme_client_and_key(data: ACMEClientAndKeyData) -> tuple[client.ClientV2 'newAccount': data['new_account_uri'], 'newNonce': data['new_nonce_uri'], 'newOrder': data['new_order_uri'], - 'revokeCert': data['revoke_cert_uri'] + 'revokeCert': data['revoke_cert_uri'], + **({'renewalInfo': data['renewal_info']} if data.get('renewal_info') else {}), }), client.ClientNetwork(key, account=registration) ), key From 0f9d210fb9275fd326375ce014e06419a42c4aca Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 11:32:33 +0500 Subject: [PATCH 04/12] Override acme order as underlying acme library does not directly expose ari support yet --- truenas_acme_utils/client_utils.py | 39 +++++++++++++++++++++++++++++- truenas_acme_utils/issue_cert.py | 4 +-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/truenas_acme_utils/client_utils.py b/truenas_acme_utils/client_utils.py index 1ea4157..648fdb8 100644 --- a/truenas_acme_utils/client_utils.py +++ b/truenas_acme_utils/client_utils.py @@ -2,7 +2,8 @@ import typing import josepy as jose -from acme import client, messages +from acme import client, crypto_util, messages +from cryptography import x509 class BodyDict(typing.TypedDict): @@ -65,3 +66,39 @@ def get_acme_client_and_key(data: ACMEClientAndKeyData) -> tuple[client.ClientV2 }), client.ClientNetwork(key, account=registration) ), key + + +def acme_order(acme_client: client.ClientV2, csr_pem: bytes) -> messages.OrderResource: + csr = x509.load_pem_x509_csr(csr_pem) + dnsNames = crypto_util.get_names_from_subject_and_extensions(csr.subject, csr.extensions) + try: + san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) + except x509.ExtensionNotFound: + ipNames = [] + else: + ipNames = san_ext.value.get_values_for_type(x509.IPAddress) + + identifiers = [] + for name in dnsNames: + identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=name)) + + for ip in ipNames: + identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP, value=str(ip))) + + order = messages.NewOrder(identifiers=identifiers) + response = acme_client._post(acme_client.directory['newOrder'], order) + body = messages.Order.from_json(response.json()) + + authorizations = [] + # pylint has trouble understanding our josepy based objects which use + # things like custom metaclass logic. body.authorizations should be a + # list of strings containing URLs so let's disable this check here. + for url in body.authorizations: # pylint: disable=not-an-iterable + authorizations.append(acme_client._authzr_from_response(acme_client._post_as_get(url), uri=url)) + + return messages.OrderResource( + body=body, + uri=response.headers.get('Location'), + authorizations=authorizations, + csr_pem=csr_pem, + ) diff --git a/truenas_acme_utils/issue_cert.py b/truenas_acme_utils/issue_cert.py index ef2d928..850f4ff 100644 --- a/truenas_acme_utils/issue_cert.py +++ b/truenas_acme_utils/issue_cert.py @@ -6,7 +6,7 @@ import josepy as jose from acme import errors, messages -from .client_utils import ACMEClientAndKeyData, get_acme_client_and_key +from .client_utils import ACMEClientAndKeyData, acme_order, get_acme_client_and_key from .event import send_event from .exceptions import CallError @@ -21,7 +21,7 @@ def issue_certificate( acme_client, key = get_acme_client_and_key(acme_client_key_payload) try: # perform operations and have a cert issued - order = acme_client.new_order(csr.encode()) + order = acme_order(acme_client, csr.encode()) except messages.Error as e: raise CallError(f'Failed to issue a new order for Certificate : {e}') else: From 22e27c8af90275843e48feb6fc2bc067b24b23ec Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 12:13:48 +0500 Subject: [PATCH 05/12] Add util to get certificate id --- truenas_crypto_utils/read.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/truenas_crypto_utils/read.py b/truenas_crypto_utils/read.py index 24fc650..908c15e 100644 --- a/truenas_crypto_utils/read.py +++ b/truenas_crypto_utils/read.py @@ -1,3 +1,4 @@ +import base64 import datetime import dateutil import dateutil.parser @@ -6,9 +7,11 @@ from contextlib import suppress from typing import TypeAlias +from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, ed448, rsa +from cryptography.x509.oid import ExtensionOID from OpenSSL import crypto from .utils import RE_CERTIFICATE @@ -20,6 +23,37 @@ logger = logging.getLogger(__name__) +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode().rstrip("=") + + +def _serial_value_bytes(n: int) -> bytes: + # DER INTEGER value bytes for non-negative n + if n == 0: + return b'\x00' + v = n.to_bytes((n.bit_length() + 7) // 8, 'big') + return v if not (v[0] & 0x80) else b'\x00' + v + + +def get_cert_id(cert_str: str) -> str: + """ + ARI cert_id per RFC 9773 ยง4.1 + format: base64url(AKI.keyIdentifier) + "." + base64url(serial INTEGER value bytes) + """ + cert = x509.load_pem_x509_certificate(cert_str.encode(), default_backend()) + try: + aki_ext = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_KEY_IDENTIFIER) + except x509.ExtensionNotFound as e: + raise ValueError('Certificate missing Authority Key Identifier (AKI)') from e + + if aki_ext.value.key_identifier is None: + raise ValueError('AKI keyIdentifier is None') + + aki_b64 = _b64url(aki_ext.value.key_identifier) + serial_b64 = _b64url(_serial_value_bytes(cert.serial_number)) + return f'{aki_b64}.{serial_b64}' + + def parse_cert_date_string(date_value: bytes | str) -> str: t1 = dateutil.parser.parse(date_value) t2 = t1.astimezone(dateutil.tz.tzlocal()) From f1063aae01f40612439f6d89c8c4405adbd49da5 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 13:14:23 +0500 Subject: [PATCH 06/12] Copy over ari querying logic --- truenas_acme_utils/ari.py | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 truenas_acme_utils/ari.py diff --git a/truenas_acme_utils/ari.py b/truenas_acme_utils/ari.py new file mode 100644 index 0000000..c3d51b5 --- /dev/null +++ b/truenas_acme_utils/ari.py @@ -0,0 +1,98 @@ +import json +import time +from datetime import datetime + +from .exceptions import CallError + + +# ARI retry configuration per RFC 9773 Section 4.3.3 +DEFAULT_RETRY_AFTER = 21600 # 6 hours default +MIN_RETRY_AFTER = 60 # 1 minute minimum +MAX_RETRY_AFTER = 86400 # 1 day maximum +MAX_RETRIES = 3 # Max temporary error retries + + +def fetch_renewal_info(acme_client, ari_endpoint: str, cert_id: str, retries: int = MAX_RETRIES) -> dict: + """ + Fetch renewal information from ACME server per RFC 9773 Section 4 + + Implements exponential backoff for temporary errors per RFC 9773 Section 4.3.3 + + :param acme_client: ACME ClientV2 instance + :param ari_endpoint: RenewalInfo endpoint URL + :param cert_id: Unique certificate identifier from get_cert_id() + :param retries: Number of retries remaining for temporary errors + :return: Dict with suggestedWindow (start/end datetimes), optional explanationURL, retry_after + """ + url = f'{ari_endpoint}/{cert_id}' + backoff_delay = 1 + start_time = time.time() + + for attempt in range(retries + 1): + try: + response = acme_client.net.get(url) + + # Check for HTTP 409 alreadyReplaced error (RFC 9773 Section 7.4) + if response.status_code == 409: + raise CallError('Certificate has already been marked as replaced') + + # Handle 5xx server errors as temporary (RFC 9773 Section 4.3.3) + if 500 <= response.status_code < 600: + if attempt < retries: + time.sleep(backoff_delay) + backoff_delay *= 2 + continue + raise CallError(f'ARI server error after {retries + 1} attempts: HTTP {response.status_code}') + + if response.status_code not in (200, 201, 204): + raise CallError(f'ARI request failed: HTTP {response.status_code}') + + data = json.loads(response.text) + break + except (ConnectionError, TimeoutError) as e: + if attempt < retries: + time.sleep(backoff_delay) + backoff_delay *= 2 + continue + raise CallError(f'ARI request failed after {retries + 1} attempts: {e}') + except Exception as e: + raise CallError(f'ARI request failed: {e}') + + elapsed_time = time.time() - start_time + + if 'suggestedWindow' not in data: + raise CallError('Invalid ARI response: missing suggestedWindow') + + window = data['suggestedWindow'] + if 'start' not in window or 'end' not in window: + raise CallError('Invalid suggestedWindow: missing start or end') + + start = datetime.fromisoformat(window['start'].replace('Z', '+00:00')) + end = datetime.fromisoformat(window['end'].replace('Z', '+00:00')) + + result = { + 'suggestedWindow': {'start': start, 'end': end}, + 'retry_after': None, + 'metrics': { + 'attempts': attempt + 1, + 'elapsed_seconds': elapsed_time, + } + } + + if 'explanationURL' in data: + result['explanationURL'] = data['explanationURL'] + + # Parse Retry-After header per RFC 9773 Section 4.3 + if 'Retry-After' in response.headers: + try: + retry_after = int(response.headers['Retry-After']) + # Clamp to reasonable limits per RFC 9773 Section 4.3.2 + retry_after = max(MIN_RETRY_AFTER, min(retry_after, MAX_RETRY_AFTER)) + result['retry_after'] = retry_after + except ValueError: + result['retry_after'] = DEFAULT_RETRY_AFTER + else: + # Use default if not provided per RFC 9773 Section 4.3.3 + result['retry_after'] = DEFAULT_RETRY_AFTER + + return result From 5ade8bf6e4cf1605689e68d5619556d4f7a52ba6 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 13:30:38 +0500 Subject: [PATCH 07/12] Refine fetch renewal info endpoint --- truenas_acme_utils/ari.py | 56 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/truenas_acme_utils/ari.py b/truenas_acme_utils/ari.py index c3d51b5..d3ba7f0 100644 --- a/truenas_acme_utils/ari.py +++ b/truenas_acme_utils/ari.py @@ -2,7 +2,7 @@ import time from datetime import datetime -from .exceptions import CallError +import requests # ARI retry configuration per RFC 9773 Section 4.3.3 @@ -12,29 +12,29 @@ MAX_RETRIES = 3 # Max temporary error retries -def fetch_renewal_info(acme_client, ari_endpoint: str, cert_id: str, retries: int = MAX_RETRIES) -> dict: +def fetch_renewal_info(ari_endpoint: str, cert_id: str, retries: int = MAX_RETRIES, timeout: int = 30) -> dict: """ Fetch renewal information from ACME server per RFC 9773 Section 4 Implements exponential backoff for temporary errors per RFC 9773 Section 4.3.3 - :param acme_client: ACME ClientV2 instance :param ari_endpoint: RenewalInfo endpoint URL :param cert_id: Unique certificate identifier from get_cert_id() :param retries: Number of retries remaining for temporary errors - :return: Dict with suggestedWindow (start/end datetimes), optional explanationURL, retry_after + :param timeout: Request timeout in seconds + :return: Dict with error field (None if success), suggestedWindow (start/end datetimes), optional explanationURL, retry_after """ url = f'{ari_endpoint}/{cert_id}' backoff_delay = 1 - start_time = time.time() + response = None for attempt in range(retries + 1): try: - response = acme_client.net.get(url) + response = requests.get(url, timeout=timeout) # Check for HTTP 409 alreadyReplaced error (RFC 9773 Section 7.4) if response.status_code == 409: - raise CallError('Certificate has already been marked as replaced') + return {'error': 'Certificate has already been marked as replaced'} # Handle 5xx server errors as temporary (RFC 9773 Section 4.3.3) if 500 <= response.status_code < 600: @@ -42,48 +42,48 @@ def fetch_renewal_info(acme_client, ari_endpoint: str, cert_id: str, retries: in time.sleep(backoff_delay) backoff_delay *= 2 continue - raise CallError(f'ARI server error after {retries + 1} attempts: HTTP {response.status_code}') + return {'error': f'ARI server error after {retries + 1} attempts: HTTP {response.status_code}'} if response.status_code not in (200, 201, 204): - raise CallError(f'ARI request failed: HTTP {response.status_code}') + return {'error': f'ARI request failed: HTTP {response.status_code}'} - data = json.loads(response.text) + data = response.json() break - except (ConnectionError, TimeoutError) as e: + + except (ConnectionError, TimeoutError, requests.exceptions.RequestException) as e: if attempt < retries: time.sleep(backoff_delay) backoff_delay *= 2 continue - raise CallError(f'ARI request failed after {retries + 1} attempts: {e}') - except Exception as e: - raise CallError(f'ARI request failed: {e}') - elapsed_time = time.time() - start_time + return {'error': f'ARI request failed after {retries + 1} attempts: {e}'} + except json.JSONDecodeError as e: + return {'error': f'Invalid JSON response: {e}'} + except Exception as e: + return {'error': f'ARI request failed: {e}'} if 'suggestedWindow' not in data: - raise CallError('Invalid ARI response: missing suggestedWindow') + return {'error': 'Invalid ARI response: missing suggestedWindow'} window = data['suggestedWindow'] if 'start' not in window or 'end' not in window: - raise CallError('Invalid suggestedWindow: missing start or end') + return {'error': 'Invalid suggestedWindow: missing start or end'} - start = datetime.fromisoformat(window['start'].replace('Z', '+00:00')) - end = datetime.fromisoformat(window['end'].replace('Z', '+00:00')) + try: + start = datetime.fromisoformat(window['start'].replace('Z', '+00:00')) + end = datetime.fromisoformat(window['end'].replace('Z', '+00:00')) + except (ValueError, TypeError) as e: + return {'error': f'Invalid date format in suggestedWindow: {e}'} result = { - 'suggestedWindow': {'start': start, 'end': end}, + 'error': None, + 'suggested_window': {'start': start, 'end': end}, 'retry_after': None, - 'metrics': { - 'attempts': attempt + 1, - 'elapsed_seconds': elapsed_time, - } + 'explanation_url': data.get('explanationURL'), } - if 'explanationURL' in data: - result['explanationURL'] = data['explanationURL'] - # Parse Retry-After header per RFC 9773 Section 4.3 - if 'Retry-After' in response.headers: + if response and 'Retry-After' in response.headers: try: retry_after = int(response.headers['Retry-After']) # Clamp to reasonable limits per RFC 9773 Section 4.3.2 From 5c845fe0b723a24afd3ceff712dc4bf1178e4f34 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 13:57:16 +0500 Subject: [PATCH 08/12] Add ability to issue an acme order with specifying cert which is to be replaced --- truenas_acme_utils/client_utils.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/truenas_acme_utils/client_utils.py b/truenas_acme_utils/client_utils.py index 648fdb8..6519d18 100644 --- a/truenas_acme_utils/client_utils.py +++ b/truenas_acme_utils/client_utils.py @@ -6,6 +6,10 @@ from cryptography import x509 +class NewOrder(messages.NewOrder): + replaces: str = jose.field('replaces', omitempty=True) + + class BodyDict(typing.TypedDict): status: str key: str @@ -68,7 +72,9 @@ def get_acme_client_and_key(data: ACMEClientAndKeyData) -> tuple[client.ClientV2 ), key -def acme_order(acme_client: client.ClientV2, csr_pem: bytes) -> messages.OrderResource: +def acme_order( + acme_client: client.ClientV2, csr_pem: bytes, replaces_cert_id: str | None = None, +) -> messages.OrderResource: csr = x509.load_pem_x509_csr(csr_pem) dnsNames = crypto_util.get_names_from_subject_and_extensions(csr.subject, csr.extensions) try: @@ -85,7 +91,11 @@ def acme_order(acme_client: client.ClientV2, csr_pem: bytes) -> messages.OrderRe for ip in ipNames: identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP, value=str(ip))) - order = messages.NewOrder(identifiers=identifiers) + payload = {'identifiers': identifiers} + if replaces_cert_id: + payload['replaces'] = replaces_cert_id + + order = NewOrder(**payload) response = acme_client._post(acme_client.directory['newOrder'], order) body = messages.Order.from_json(response.json()) From 6ce1323a98db3c286262bfae7aad4f68ee4dd8c9 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 14:03:53 +0500 Subject: [PATCH 09/12] Expose cert_id in issuance of certs --- truenas_acme_utils/issue_cert.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/truenas_acme_utils/issue_cert.py b/truenas_acme_utils/issue_cert.py index 850f4ff..901663b 100644 --- a/truenas_acme_utils/issue_cert.py +++ b/truenas_acme_utils/issue_cert.py @@ -15,13 +15,15 @@ def issue_certificate( - acme_client_key_payload: ACMEClientAndKeyData, csr: str, authenticator_mapping_copy: dict, progress_base: int = 25 + acme_client_key_payload: ACMEClientAndKeyData, csr: str, authenticator_mapping_copy: dict, progress_base: int = 25, + cert_id: str | None = None, ) -> messages.OrderResource: + # cert_id is the ID of the certificate being replaced if any # Authenticator mapping should be a valid mapping of domain to authenticator object acme_client, key = get_acme_client_and_key(acme_client_key_payload) try: # perform operations and have a cert issued - order = acme_order(acme_client, csr.encode()) + order = acme_order(acme_client, csr.encode(), cert_id) except messages.Error as e: raise CallError(f'Failed to issue a new order for Certificate : {e}') else: From c4e6c02a92388914d85101f5494513b51f114e78 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 14:50:30 +0500 Subject: [PATCH 10/12] Properly handle trailing slashes --- truenas_acme_utils/ari.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/truenas_acme_utils/ari.py b/truenas_acme_utils/ari.py index d3ba7f0..3a0c108 100644 --- a/truenas_acme_utils/ari.py +++ b/truenas_acme_utils/ari.py @@ -24,7 +24,7 @@ def fetch_renewal_info(ari_endpoint: str, cert_id: str, retries: int = MAX_RETRI :param timeout: Request timeout in seconds :return: Dict with error field (None if success), suggestedWindow (start/end datetimes), optional explanationURL, retry_after """ - url = f'{ari_endpoint}/{cert_id}' + url = f'{ari_endpoint.rstrip("/")}/{cert_id}' backoff_delay = 1 response = None From 9922c4e0950e6c9e89db9c8e801b9e28230abddc Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 15:55:19 +0500 Subject: [PATCH 11/12] Change kwarg name --- truenas_acme_utils/issue_cert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/truenas_acme_utils/issue_cert.py b/truenas_acme_utils/issue_cert.py index 901663b..f04a799 100644 --- a/truenas_acme_utils/issue_cert.py +++ b/truenas_acme_utils/issue_cert.py @@ -16,14 +16,14 @@ def issue_certificate( acme_client_key_payload: ACMEClientAndKeyData, csr: str, authenticator_mapping_copy: dict, progress_base: int = 25, - cert_id: str | None = None, + cert_renewal_id: str | None = None, ) -> messages.OrderResource: # cert_id is the ID of the certificate being replaced if any # Authenticator mapping should be a valid mapping of domain to authenticator object acme_client, key = get_acme_client_and_key(acme_client_key_payload) try: # perform operations and have a cert issued - order = acme_order(acme_client, csr.encode(), cert_id) + order = acme_order(acme_client, csr.encode(), cert_renewal_id) except messages.Error as e: raise CallError(f'Failed to issue a new order for Certificate : {e}') else: From 34325377df361bab45e75594fd97dd13941f493a Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Tue, 7 Oct 2025 19:35:10 +0500 Subject: [PATCH 12/12] Fix flake8 --- truenas_acme_utils/ari.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/truenas_acme_utils/ari.py b/truenas_acme_utils/ari.py index 3a0c108..16b76b3 100644 --- a/truenas_acme_utils/ari.py +++ b/truenas_acme_utils/ari.py @@ -22,7 +22,8 @@ def fetch_renewal_info(ari_endpoint: str, cert_id: str, retries: int = MAX_RETRI :param cert_id: Unique certificate identifier from get_cert_id() :param retries: Number of retries remaining for temporary errors :param timeout: Request timeout in seconds - :return: Dict with error field (None if success), suggestedWindow (start/end datetimes), optional explanationURL, retry_after + :return: Dict with error field (None if success), suggestedWindow (start/end datetimes), + optional explanationURL, retry_after """ url = f'{ari_endpoint.rstrip("/")}/{cert_id}' backoff_delay = 1