Skip to content

Commit

Permalink
Merge pull request #22 from NabuCasa/dev
Browse files Browse the repository at this point in the history
Release 0.4
  • Loading branch information
pvizeli authored Mar 12, 2019
2 parents 7a84510 + b0e3733 commit 64339f7
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 52 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ celerybeat-schedule
*.sage.py

# Environments
bin
.env
.venv
env/
Expand Down
76 changes: 48 additions & 28 deletions hass_nabucasa/acme.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import attr
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
import josepy as jose
Expand Down Expand Up @@ -62,6 +62,7 @@ def __init__(self, cloud, domain: str, email: str) -> None:
self._acme_server = cloud.acme_directory_server
self._account_jwk = None
self._acme_client = None
self._x509 = None

self._domain = domain
self._email = email
Expand All @@ -86,6 +87,40 @@ def path_registration_info(self) -> Path:
"""Return path of acme client registration file."""
return Path(self.cloud.path(FILE_REGISTRATION))

@property
def certificate_available(self) -> bool:
"""Return True if a certificate is loaded."""
return self._x509 is not None

@property
def is_valid_certificate(self) -> bool:
"""Validate date of a certificate and return True is valid."""
if not self._x509:
return False
return self._x509.not_valid_after > datetime.utcnow()

@property
def expire_date(self) -> Optional[datetime]:
"""Return datetime of expire date for certificate."""
if not self._x509:
return None
return self._x509.not_valid_after

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

@property
def fingerprint(self) -> Optional[str]:
"""Return SHA1 hex string as fingerprint."""
if not self._x509:
return None
fingerprint = self._x509.fingerprint(hashes.SHA1())
return fingerprint.hex()

def _generate_csr(self) -> bytes:
"""Load or create private key."""
if self.path_private_key.exists():
Expand Down Expand Up @@ -242,36 +277,19 @@ def _finish_challenge(self, handler: ChallengeHandler) -> None:
self.path_fullchain.write_text(order.fullchain_pem)
self.path_fullchain.chmod(0o600)

def _get_cert(self) -> Optional[x509.Certificate]:
async def load_certificate(self) -> None:
"""Get x509 Cert-Object."""
if not self.path_fullchain.exists():
return None

return x509.load_pem_x509_certificate(
self.path_fullchain.read_bytes(),
default_backend()
)

async def is_valid_certificate(self) -> bool:
"""Validate date of a certificate and return True is valid."""
cert = await self.cloud.run_executor(self._get_cert)
if not cert:
return False
return cert.not_valid_after > datetime.utcnow()

async def get_expire_date(self) -> Optional[datetime]:
"""Return datetime of expire date for certificate."""
cert = await self.cloud.run_executor(self._get_cert)
if not cert:
if self._x509 or not self.path_fullchain.exists():
return
return cert.not_valid_after

async def get_common_name(self) -> Optional[str]:
"""Return CommonName of certificate."""
cert = await self.cloud.run_executor(self._get_cert)
if not cert:
return
return cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
def _load_cert():
"""Load certificate in a thread."""
return x509.load_pem_x509_certificate(
self.path_fullchain.read_bytes(),
default_backend()
)

self._x509 = await self.cloud.run_executor(_load_cert)

def _revoke_certificate(self) -> None:
"""Revoke certificate."""
Expand Down Expand Up @@ -340,6 +358,7 @@ async def issue_certificate(self) -> None:
_LOGGER.info("Wait 60sec for publishing DNS to ACME provider")
await asyncio.sleep(60)
await self.cloud.run_executor(self._finish_challenge, challenge)
await self.load_certificate()
finally:
await cloud_api.async_remote_challenge_cleanup(self.cloud, challenge.validation)

Expand All @@ -355,3 +374,4 @@ async def reset_acme(self) -> None:
finally:
self._acme_client = None
self._account_jwk = None
self._x509 = None
5 changes: 5 additions & 0 deletions hass_nabucasa/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ def cloudhooks(self) -> Dict[str, Dict[str, str]]:
"""Return list of cloudhooks."""
raise NotImplementedError()

@property
def remote_autostart(self) -> bool:
"""Return true if we want start a remote connection."""
raise NotImplementedError()

async def cleanups(self) -> None:
"""Called on logout."""
raise NotImplementedError()
Expand Down
15 changes: 11 additions & 4 deletions hass_nabucasa/cloudhooks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Manage cloud cloudhooks."""
from typing import Dict, Any

import async_timeout

from . import cloud_api
Expand All @@ -10,18 +12,22 @@ class Cloudhooks:
def __init__(self, cloud):
"""Initialize cloudhooks."""
self.cloud = cloud
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)

async def async_publish_cloudhooks(self):
cloud.iot.register_on_connect(self.async_publish_cloudhooks)

async def async_publish_cloudhooks(self) -> None:
"""Inform the Relayer of the cloudhooks that we support."""
if not self.cloud.is_connected:
return

cloudhooks = self.cloud.client.cloudhooks
await self.cloud.iot.async_send_message(
"webhook-register",
{"cloudhook_ids": [info["cloudhook_id"] for info in cloudhooks.values()]},
expect_answer=False,
)

async def async_create(self, webhook_id):
async def async_create(self, webhook_id: str, managed: bool) -> Dict[str, Any]:
"""Create a cloud webhook."""
cloudhooks = self.cloud.client.cloudhooks

Expand All @@ -45,13 +51,14 @@ async def async_create(self, webhook_id):
"webhook_id": webhook_id,
"cloudhook_id": cloudhook_id,
"cloudhook_url": cloudhook_url,
"managed": managed,
}
await self.cloud.client.async_cloudhooks_update(cloudhooks)

await self.async_publish_cloudhooks()
return hook

async def async_delete(self, webhook_id):
async def async_delete(self, webhook_id: str) -> None:
"""Delete a cloud webhook."""
cloudhooks = self.cloud.client.cloudhooks

Expand Down
48 changes: 40 additions & 8 deletions hass_nabucasa/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ class SniTunToken:
valid = attr.ib(type=datetime)


@attr.s
class Certificate:
"""Handle certificate details."""

common_name = attr.ib(type=str)
expire_date = attr.ib(type=datetime)
fingerprint = attr.ib(type=str)


class RemoteUI:
"""Class to help manage remote connections."""

Expand Down Expand Up @@ -68,6 +77,23 @@ def instance_domain(self) -> Optional[str]:
"""Return instance domain."""
return self._instance_domain

@property
def is_connected(self) -> bool:
"""Return true if we are ready to connect."""
if not self._snitun:
return False
return self._snitun.is_connected

@property
def certificate(self) -> Optional[Certificate]:
"""Return certificate details."""
if not self._acme or not self._acme.certificate_available:
return None

return Certificate(
self._acme.common_name, self._acme.expire_date, self._acme.fingerprint
)

async def _create_context(self) -> ssl.SSLContext:
"""Create SSL context with acme certificate."""
context = server_context_modern()
Expand Down Expand Up @@ -103,14 +129,17 @@ async def load_backend(self) -> None:
# Set instance details for certificate
self._acme = AcmeHandler(self.cloud, domain, email)

# Load exists certificate
await self._acme.load_certificate()

# Domain changed / revoke CA
ca_domain = await self._acme.get_common_name()
ca_domain = self._acme.common_name
if ca_domain is not None and ca_domain != domain:
_LOGGER.warning("Invalid certificate found: %s", ca_domain)
await self._acme.reset_acme()

# Issue a certificate
if not await self._acme.is_valid_certificate():
if not self._acme.is_valid_certificate:
try:
await self._acme.issue_certificate()
except AcmeClientError:
Expand All @@ -129,7 +158,10 @@ async def load_backend(self) -> None:
self._snitun_server = server

await self._snitun.start()
self.cloud.run_task(self.connect())

# Connect to remote is autostart enabled
if self.cloud.client.remote_autostart:
self.cloud.run_task(self.connect())

async def close_backend(self) -> None:
"""Close connections and shutdown backend."""
Expand All @@ -147,7 +179,7 @@ async def close_backend(self) -> None:
self._instance_domain = None
self._snitun_server = None

async def handle_connection_requests(self, caller_ip):
async def handle_connection_requests(self, caller_ip: str) -> None:
"""Handle connection requests."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
Expand All @@ -157,7 +189,7 @@ async def handle_connection_requests(self, caller_ip):
return
await self.connect()

async def _refresh_snitun_token(self):
async def _refresh_snitun_token(self) -> None:
"""Handle snitun token."""
if self._token and self._token.valid > utcnow():
_LOGGER.debug("Don't need refresh snitun token")
Expand All @@ -176,7 +208,7 @@ async def _refresh_snitun_token(self):
data["token"].encode(), aes_key, aes_iv, utc_from_timestamp(data["valid"])
)

async def connect(self):
async def connect(self) -> None:
"""Connect to snitun server."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
Expand All @@ -200,7 +232,7 @@ async def connect(self):
if not self._reconnect_task:
self._reconnect_task = self.cloud.run_task(self._reconnect_snitun())

async def disconnect(self):
async def disconnect(self) -> None:
"""Disconnect from snitun server."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
Expand All @@ -215,7 +247,7 @@ async def disconnect(self):
return
await self._snitun.disconnect()

async def _reconnect_snitun(self):
async def _reconnect_snitun(self) -> None:
"""Reconnect after disconnect."""
try:
while True:
Expand Down
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import setup

VERSION = "0.3"
VERSION = "0.4"

setup(
name="hass-nabucasa",
Expand Down Expand Up @@ -30,8 +30,8 @@
packages=["hass_nabucasa"],
install_requires=[
"warrant==0.6.1",
"snitun==0.12",
"acme==0.31.0",
"snitun==0.13",
"acme==0.32.0",
"cryptography>=2.5",
"attrs>=18.2.0",
"pytz",
Expand Down
25 changes: 21 additions & 4 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test the helper method for writing tests."""
import asyncio
from datetime import datetime
from pathlib import Path
import tempfile
from typing import Optional
Expand Down Expand Up @@ -32,6 +33,7 @@ def __init__(self, loop, websession):
self._loop = loop
self._websession = websession
self._cloudhooks = {}
self.prop_remote_autostart = True

self.mock_user = []
self.mock_alexa = []
Expand Down Expand Up @@ -65,6 +67,11 @@ def cloudhooks(self):
"""Return list of cloudhooks."""
return self._cloudhooks

@property
def remote_autostart(self) -> bool:
"""Return true if we want start a remote connection."""
return self.prop_remote_autostart

async def cleanups(self):
"""Need nothing to do."""

Expand Down Expand Up @@ -102,17 +109,23 @@ def __init__(self):
self.is_valid = True
self.call_issue = False
self.call_reset = False
self.call_load = False
self.init_args = None

self.common_name = None
self.expire_date = None
self.fingerprint = None

def set_false(self):
self.is_valid = False

async def get_common_name(self) -> Optional[str]:
"""Return common name."""
return self.common_name
@property
def certificate_available(self) -> bool:
"""Return true if certificate is available."""
return self.common_name is not None

async def is_valid_certificate(self) -> bool:
@property
def is_valid_certificate(self) -> bool:
"""Return valid certificate."""
return self.is_valid

Expand All @@ -124,6 +137,10 @@ async def reset_acme(self):
"""Issue a certificate."""
self.call_reset = True

async def load_certificate(self):
"""Load certificate."""
self.call_load = True

def __call__(self, *args):
"""Init."""
self.init_args = args
Expand Down
Loading

0 comments on commit 64339f7

Please sign in to comment.