diff --git a/hass_nabucasa/const.py b/hass_nabucasa/const.py index b53c9d54c..c04b6923d 100644 --- a/hass_nabucasa/const.py +++ b/hass_nabucasa/const.py @@ -39,3 +39,12 @@ to verify your credentials. Please [log in](/config/cloud) again to continue using the service. """ + +MESSAGE_REMOTE_READY = """ +You remote access is now available. +You can manage your connectivity on the [Cloud Panel](/config/cloud) or with our [Portal](https://remote.nabucasa.com/). +""" + +MESSAGE_REMOTE_SETUP = """ +Unable to create a certificate. We will automatically retry it and notify you when it's available. +""" diff --git a/hass_nabucasa/remote.py b/hass_nabucasa/remote.py index 3e1d206b3..d37a77f5a 100644 --- a/hass_nabucasa/remote.py +++ b/hass_nabucasa/remote.py @@ -1,6 +1,6 @@ """Manage remote UI connections.""" import asyncio -from datetime import datetime +from datetime import datetime, timedelta import logging import random import ssl @@ -12,9 +12,9 @@ from snitun.utils.aes import generate_aes_keyset from snitun.utils.aiohttp_client import SniTunClientAioHttp -from . import cloud_api +from . import cloud_api, utils from .acme import AcmeClientError, AcmeHandler -from .utils import server_context_modern, utcnow, utc_from_timestamp +from .const import MESSAGE_REMOTE_SETUP, MESSAGE_REMOTE_READY _LOGGER = logging.getLogger(__name__) @@ -61,6 +61,7 @@ def __init__(self, cloud): self._snitun_server = None self._instance_domain = None self._reconnect_task = None + self._acme_task = None self._token = None # Register start/stop @@ -96,7 +97,7 @@ def certificate(self) -> Optional[Certificate]: async def _create_context(self) -> ssl.SSLContext: """Create SSL context with acme certificate.""" - context = server_context_modern() + context = utils.server_context_modern() await self.cloud.run_executor( context.load_cert_chain, @@ -111,13 +112,17 @@ async def load_backend(self) -> None: if self._snitun: return + # Setup background task for ACME certification handler + if not self._acme_task: + self._acme_task = self.cloud.run_task(self._certificate_handler()) + # Load instance data from backend async with async_timeout.timeout(10): resp = await cloud_api.async_remote_register(self.cloud) if resp.status != 200: _LOGGER.error("Can't update remote details from Home Assistant cloud") - raise RemoteBackendError() + return data = await resp.json() # Extract data @@ -143,9 +148,18 @@ async def load_backend(self) -> None: try: await self._acme.issue_certificate() except AcmeClientError: - _LOGGER.error("ACME certification fails. Please try later.") - raise RemoteBackendError() - self._instance_domain = domain + await self.cloud.client.async_user_message( + "cloud_remote_acme", + "Home Assistant Cloud", + MESSAGE_REMOTE_SETUP + ) + return + else: + await self.cloud.client.async_user_message( + "cloud_remote_acme", + "Home Assistant Cloud", + MESSAGE_REMOTE_READY, + ) # Setup snitun / aiohttp wrapper context = await self._create_context() @@ -155,6 +169,9 @@ async def load_backend(self) -> None: snitun_server=server, snitun_port=443, ) + + # Cache data + self._instance_domain = domain self._snitun_server = server await self._snitun.start() @@ -165,9 +182,14 @@ async def load_backend(self) -> None: async def close_backend(self) -> None: """Close connections and shutdown backend.""" + # Close reconnect task if self._reconnect_task: self._reconnect_task.cancel() + # Close ACME certificate handler + if self._acme_task: + self._acme_task.cancel() + # Disconnect snitun if self._snitun: await self._snitun.stop() @@ -191,7 +213,7 @@ async def handle_connection_requests(self, caller_ip: str) -> None: async def _refresh_snitun_token(self) -> None: """Handle snitun token.""" - if self._token and self._token.valid > utcnow(): + if self._token and self._token.valid > utils.utcnow(): _LOGGER.debug("Don't need refresh snitun token") return @@ -205,7 +227,10 @@ async def _refresh_snitun_token(self) -> None: data = await resp.json() self._token = SniTunToken( - data["token"].encode(), aes_key, aes_iv, utc_from_timestamp(data["valid"]) + data["token"].encode(), + aes_key, + aes_iv, + utils.utc_from_timestamp(data["valid"]), ) async def connect(self) -> None: @@ -260,3 +285,33 @@ async def _reconnect_snitun(self) -> None: pass finally: self._reconnect_task = None + + async def _certificate_handler(self) -> None: + """Handle certification ACME Tasks.""" + try: + while True: + await asyncio.sleep(utils.next_midnight()) + + # Backend not initialize / No certificate issue now + if not self._snitun: + await self.load_backend() + continue + + # Renew certificate? + if self._acme.expire_date > utils.utcnow() + timedelta(days=14): + continue + + # Renew certificate + try: + await self._acme.issue_certificate() + await self.close_backend() + await self.load_backend() + except AcmeClientError: + _LOGGER.warning( + "Renew of ACME certificate fails. Try it lager again" + ) + + except asyncio.CancelledError: + pass + finally: + self._acme_task = None diff --git a/hass_nabucasa/utils.py b/hass_nabucasa/utils.py index da03b9427..0cd312650 100644 --- a/hass_nabucasa/utils.py +++ b/hass_nabucasa/utils.py @@ -57,6 +57,14 @@ def server_context_modern() -> ssl.SSLContext: return context +def next_midnight() -> int: + """Return the seconds till next midnight.""" + midnight = dt.datetime.utcnow().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + dt.timedelta(days=1) + return (midnight - dt.datetime.utcnow()).total_seconds() + + class Registry(dict): """Registry of items.""" diff --git a/requirements_tests.txt b/requirements_tests.txt index bece37189..c5321f2a2 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -1,5 +1,5 @@ flake8==3.7.7 -pylint==2.3.0 +pylint==2.3.1 pytest==4.3.0 pytest-timeout==1.3.3 pytest-aiohttp==0.3.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 93241f8f6..c6167f99e 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup -VERSION = "0.4" +VERSION = "0.5" setup( name="hass-nabucasa", diff --git a/tests/test_remote.py b/tests/test_remote.py index dba5d8aa6..acf8c88a9 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -89,6 +89,9 @@ async def test_load_backend_exists_cert( assert snitun_mock.connect_args[0] == b"test-token" assert remote.is_connected + assert remote._acme_task + assert remote._reconnect_task + async def test_load_backend_not_exists_cert( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock @@ -136,6 +139,9 @@ async def test_load_backend_not_exists_cert( assert snitun_mock.call_connect assert snitun_mock.connect_args[0] == b"test-token" + assert remote._acme_task + assert remote._reconnect_task + async def test_load_and_unload_backend( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock @@ -180,11 +186,17 @@ async def test_load_and_unload_backend( "snitun_port": 443, } + assert remote._acme_task + assert remote._reconnect_task + await remote.close_backend() await asyncio.sleep(0.1) assert snitun_mock.call_stop + assert not remote._acme_task + assert not remote._reconnect_task + async def test_load_backend_exists_wrong_cert( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock @@ -310,7 +322,8 @@ async def test_load_backend_no_autostart( async def test_get_certificate_details( - cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock): + cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): """Initialize backend.""" valid = utcnow() + timedelta(days=1) cloud_mock.remote_api_url = "https://test.local/api" @@ -348,3 +361,74 @@ async def test_get_certificate_details( assert certificate.common_name == "test" assert certificate.expire_date == valid assert certificate.fingerprint == "ffff" + + +async def test_certificate_task_no_backend( + loop, cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): + """Initialize backend.""" + valid = utcnow() + timedelta(days=1) + cloud_mock.remote_api_url = "https://test.local/api" + remote = RemoteUI(cloud_mock) + + aioclient_mock.post( + "https://test.local/api/register_instance", + json={ + "domain": "test.dui.nabu.casa", + "email": "test@nabucasa.inc", + "server": "rest-remote.nabu.casa", + }, + ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) + + acme_mock.expire_date = valid + + with patch("hass_nabucasa.utils.next_midnight", return_value=0) as mock_midnight: + remote._acme_task = loop.create_task(remote._certificate_handler()) + + await asyncio.sleep(0.1) + assert mock_midnight.called + assert acme_mock.call_issue + assert snitun_mock.call_start + + +async def test_certificate_task_renew_cert( + loop, cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): + """Initialize backend.""" + valid = utcnow() + timedelta(days=1) + cloud_mock.remote_api_url = "https://test.local/api" + remote = RemoteUI(cloud_mock) + + aioclient_mock.post( + "https://test.local/api/register_instance", + json={ + "domain": "test.dui.nabu.casa", + "email": "test@nabucasa.inc", + "server": "rest-remote.nabu.casa", + }, + ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) + + acme_mock.expire_date = utcnow() + timedelta(days=-40) + + with patch("hass_nabucasa.utils.next_midnight", return_value=0) as mock_midnight: + remote._acme_task = loop.create_task(remote._certificate_handler()) + + await remote.load_backend() + await asyncio.sleep(0.1) + assert acme_mock.call_issue