Skip to content

Commit

Permalink
Handle ACME errors and show messages to users (#23)
Browse files Browse the repository at this point in the history
* Handle ACME errors and show messages to users

* fix tests

* Fix background task startup

* Add tests

* Address comments

* Fix message

* Fix break
  • Loading branch information
pvizeli authored Mar 13, 2019
1 parent 7312370 commit 91311fc
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 12 deletions.
9 changes: 9 additions & 0 deletions hass_nabucasa/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
75 changes: 65 additions & 10 deletions hass_nabucasa/remote.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions hass_nabucasa/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 1 addition & 1 deletion requirements_tests.txt
Original file line number Diff line number Diff line change
@@ -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
86 changes: 85 additions & 1 deletion tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": "[email protected]",
"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": "[email protected]",
"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

0 comments on commit 91311fc

Please sign in to comment.