Skip to content

Commit

Permalink
Merge branch 'master' into dependabot/pip/types-pyopenssl-23.2.0.2
Browse files Browse the repository at this point in the history
  • Loading branch information
ludeeus authored Oct 13, 2023
2 parents 53e5be2 + 648d07b commit b049e11
Show file tree
Hide file tree
Showing 20 changed files with 427 additions and 143 deletions.
35 changes: 35 additions & 0 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Report an issue with the hass-nabucasa package
description: Report an issue with the hass-nabucasa package.
body:
- type: textarea
validations:
required: true
attributes:
label: The problem
description: >-
Describe the issue you are experiencing here, to communicate to the
maintainers. Tell us what you were trying to do and what happened.
Provide a clear and concise description of what the problem is.
- type: input
validations:
required: true
attributes:
label: What version of the package with the issue?
- type: input
validations:
required: true
attributes:
label: What python version are you using?
- type: input
attributes:
label: What was the last working version of the package?
description: >
If known, otherwise leave blank.
- type: textarea
validations:
required: true
attributes:
label: Debug logs?
description: Here you can paste debug logs for the package.
render: txt
20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
blank_issues_enabled: false
contact_links:
- name: I have an issue with my cloud account
url: https://nabucasa.com/support/
about: If You have issues related to your cloud account you need to create a ticket on the Nabu Casa website.
- name: I have an issue with Google Assistant
url: https://github.com/home-assistant/core/issues/new?assignees=&labels=&projects=&template=bug_report.yml&integration_name=google_assistant&integration_link=https://www.home-assistant.io/integrations/google_assistant/
about: For issues related to Google Assistant create a ticket on the Nabu Casa website or a issue in the Home Assistant core repository.
- name: I have an issue with Alexa
url: https://github.com/home-assistant/core/issues/new?assignees=&labels=&projects=&template=bug_report.yml&integration_name=alexa&integration_link=https://www.home-assistant.io/integrations/alexa/
about: For issues related to Alexa create a ticket on the Nabu Casa website or a issue in the Home Assistant core repository.
- name: I have an issue with remote connection
url: https://nabucasa.com/support/
about: For issues related to the remote connection create a ticket on the Nabu Casa website.
- name: I have an issue with payments
url: https://nabucasa.com/support/
about: For issues related to payments create a ticket on the Nabu Casa website.
- name: I want to request a feature to be added
url: https://community.home-assistant.io/tags/c/feature-requests/13/cloud
about: Please use the forums for feature requests
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- "3.11"

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pythonpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
Expand Down
3 changes: 0 additions & 3 deletions hass_nabucasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ def __init__(
alexa_server: str | None = None,
cloudhook_server: str | None = None,
relayer_server: str | None = None,
remote_sni_server: str | None = None,
remotestate_server: str | None = None,
thingtalk_server: str | None = None,
servicehandlers_server: str | None = None,
Expand Down Expand Up @@ -90,7 +89,6 @@ def __init__(
self.alexa_server = alexa_server
self.cloudhook_server = cloudhook_server
self.relayer_server = relayer_server
self.remote_sni_server = remote_sni_server
self.remotestate_server = remotestate_server
self.thingtalk_server = thingtalk_server
self.servicehandlers_server = servicehandlers_server
Expand All @@ -110,7 +108,6 @@ def __init__(
self.alexa_server = _servers["alexa"]
self.cloudhook_server = _servers["cloudhook"]
self.relayer_server = _servers["relayer"]
self.remote_sni_server = _servers["remote_sni"]
self.remotestate_server = _servers["remotestate"]
self.thingtalk_server = _servers["thingtalk"]
self.servicehandlers_server = _servers["servicehandlers"]
Expand Down
142 changes: 99 additions & 43 deletions hass_nabucasa/acme.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from cryptography.x509.oid import NameOID, ExtensionOID
import josepy as jose

from . import cloud_api
Expand Down Expand Up @@ -63,15 +63,15 @@ class ChallengeHandler:
class AcmeHandler:
"""Class handle a local certification."""

def __init__(self, cloud: Cloud[_ClientT], domain: str, email: str) -> None:
def __init__(self, cloud: Cloud[_ClientT], domains: list[str], email: str) -> None:
"""Initialize local ACME Handler."""
self.cloud = cloud
self._acme_server = f"https://{cloud.acme_server}/directory"
self._account_jwk: jose.JWKRSA | None = None
self._acme_client: client.ClientV2 | None = None
self._x509: x509.Certificate | None = None

self._domain = domain
self._domains = domains
self._email = email

@property
Expand Down Expand Up @@ -114,11 +114,25 @@ def expire_date(self) -> datetime | None:
return self._x509.not_valid_after.replace(tzinfo=UTC)

@property
def common_name(self) -> str | bytes | None:
def common_name(self) -> str | None:
"""Return CommonName of certificate."""
if not self._x509:
return None
return self._x509.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
return str(
self._x509.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
)

@property
def alternative_names(self) -> list[str] | None:
"""Return alternative names of certificate."""
if not self._x509:
return None
return [
str(entry.value)
for entry in self._x509.extensions.get_extension_for_oid(
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
).value
]

@property
def fingerprint(self) -> str | None:
Expand All @@ -142,7 +156,7 @@ def _generate_csr(self) -> bytes:
self.path_private_key.write_bytes(key_pem)
self.path_private_key.chmod(0o600)

return crypto_util.make_csr(key_pem, [self._domain])
return crypto_util.make_csr(key_pem, self._domains)

def _load_account_key(self) -> None:
"""Load or create account key."""
Expand Down Expand Up @@ -236,58 +250,78 @@ def _create_client(self) -> None:
)
self.path_registration_info.chmod(0o600)

def _start_challenge(self, csr_pem: bytes) -> ChallengeHandler:
def _create_order(self, csr_pem: bytes) -> messages.OrderResource:
"""Initialize domain challenge and return token."""
_LOGGER.info("Initialize challenge for a new ACME certificate")
assert self._acme_client is not None
try:
order = self._acme_client.new_order(csr_pem)
return self._acme_client.new_order(csr_pem)
except errors.Error as err:
raise AcmeChallengeError(
f"Can't order a new ACME challenge: {err}"
) from None

def _start_challenge(self, order: messages.OrderResource) -> list[ChallengeHandler]:
"""Initialize domain challenge and return token."""
_LOGGER.info("Start challenge for a new ACME certificate")

# Find DNS challenge
# pylint: disable=not-an-iterable
dns_challenge: messages.ChallengeBody | None = None
dns_challenges: list[messages.ChallengeBody] = []
for auth in order.authorizations:
for challenge in auth.body.challenges:
if challenge.typ != "dns-01":
continue
dns_challenge = challenge
dns_challenges.append(challenge)

if dns_challenge is None:
if len(dns_challenges) == 0:
raise AcmeChallengeError("No pending ACME challenge")

try:
response, validation = dns_challenge.response_and_validation(
self._account_jwk
handlers = []

for dns_challenge in dns_challenges:
try:
response, validation = dns_challenge.response_and_validation(
self._account_jwk
)
except errors.Error as err:
raise AcmeChallengeError(
f"Can't validate the new ACME challenge: {err}"
) from None
handlers.append(
ChallengeHandler(dns_challenge, order, response, validation)
)
except errors.Error as err:
raise AcmeChallengeError(
f"Can't validate the new ACME challenge: {err}"
) from None

return ChallengeHandler(dns_challenge, order, response, validation)
return handlers

def _finish_challenge(self, handler: ChallengeHandler) -> None:
"""Wait until challenge is finished."""
_LOGGER.info("Finishing challenge for the new ACME certificate")
assert self._acme_client is not None
def _answer_challenge(self, handler: ChallengeHandler) -> None:
"""Answer challenge."""
_LOGGER.info("Answer challenge for the new ACME certificate")
if TYPE_CHECKING:
assert self._acme_client is not None
try:
self._acme_client.answer_challenge(handler.challenge, handler.response)
except errors.Error as err:
raise AcmeChallengeError(f"Can't accept ACME challenge: {err}") from err

def _finish_challenge(self, order: messages.OrderResource) -> None:
"""Wait until challenge is finished."""
# Wait until it's authorize and fetch certification
if TYPE_CHECKING:
assert self._acme_client is not None
deadline = datetime.now() + timedelta(seconds=90)
try:
order = self._acme_client.poll_authorizations(handler.order, deadline)
order = self._acme_client.poll_authorizations(order, deadline)
order = self._acme_client.finalize_order(
order, deadline, fetch_alternative_chains=True
)
except errors.Error as err:
raise AcmeChallengeError(f"Wait of ACME challenge fails: {err}") from err
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception while finalizing order")
raise AcmeChallengeError(
"Unexpected exception while finalizing order"
) from None

# Cleanup the old stuff
if self.path_fullchain.exists():
Expand Down Expand Up @@ -377,35 +411,57 @@ async def issue_certificate(self) -> None:

# Initialize challenge / new certificate
csr = await self.cloud.run_executor(self._generate_csr)
challenge = await self.cloud.run_executor(self._start_challenge, csr)

# Update DNS
try:
async with async_timeout.timeout(30):
resp = await cloud_api.async_remote_challenge_txt(
self.cloud, challenge.validation
)
assert resp.status == 200
except (asyncio.TimeoutError, AssertionError):
raise AcmeNabuCasaError(
"Can't set challenge token to NabuCasa DNS!"
) from None
order = await self.cloud.run_executor(self._create_order, csr)
dns_challenges: list[ChallengeHandler] = await self.cloud.run_executor(
self._start_challenge, order
)

# Finish validation
try:
_LOGGER.info("Waiting 60 seconds for publishing DNS to ACME provider")
await asyncio.sleep(60)
await self.cloud.run_executor(self._finish_challenge, challenge)
await self.load_certificate()
for challenge in dns_challenges:
# Update DNS
try:
async with async_timeout.timeout(30):
resp = await cloud_api.async_remote_challenge_txt(
self.cloud, challenge.validation
)
assert resp.status in (200, 201)
except (asyncio.TimeoutError, AssertionError):
raise AcmeNabuCasaError(
"Can't set challenge token to NabuCasa DNS!"
) from None

# Answer challenge
try:
_LOGGER.info(
"Waiting 60 seconds for publishing DNS to ACME provider"
)
await asyncio.sleep(60)
await self.cloud.run_executor(self._answer_challenge, challenge)
except AcmeChallengeError as err:
_LOGGER.error("Could not complete answer challenge - %s", err)
# There is no point in continuing here
break
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception while answering challenge")
# There is no point in continuing here
break
finally:
try:
async with async_timeout.timeout(30):
# We only need to cleanup for the last entry
await cloud_api.async_remote_challenge_cleanup(
self.cloud, challenge.validation
self.cloud, dns_challenges[-1].validation
)
except asyncio.TimeoutError:
_LOGGER.error("Failed to clean up challenge from NabuCasa DNS!")

# Finish validation
try:
await self.cloud.run_executor(self._finish_challenge, order)
except AcmeChallengeError as err:
raise AcmeNabuCasaError(f"Could not finish challenge - {err}") from err
await self.load_certificate()

async def reset_acme(self) -> None:
"""Revoke and deactivate acme certificate/account."""
_LOGGER.info("Revoke and deactivate ACME user/certificate")
Expand Down
5 changes: 5 additions & 0 deletions hass_nabucasa/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ def loop(self) -> asyncio.AbstractEventLoop:
def websession(self) -> aiohttp.ClientSession:
"""Return client session for aiohttp."""

@property
@abstractmethod
def client_name(self) -> str:
"""Return name of the client, this will be used as the user-agent."""

@property
@abstractmethod
def aiohttp_runner(self) -> web.AppRunner | None:
Expand Down
Loading

0 comments on commit b049e11

Please sign in to comment.