Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ omit =
homeassistant/components/canary/alarm_control_panel.py
homeassistant/components/canary/camera.py
homeassistant/components/cast/*
homeassistant/components/cert_expiry/sensor.py
homeassistant/components/cert_expiry/__init__.py
homeassistant/components/cert_expiry/errors.py
homeassistant/components/cert_expiry/helper.py
homeassistant/components/cert_expiry/sensor.py
homeassistant/components/channels/*
homeassistant/components/cisco_ios/device_tracker.py
homeassistant/components/cisco_mobility_express/device_tracker.py
Expand Down
22 changes: 19 additions & 3 deletions homeassistant/components/cert_expiry/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,31 @@ async def _test_connection(self, user_input=None):
except socket.timeout:
_LOGGER.error("Timed out connecting to %s", host)
self._errors[CONF_HOST] = "connection_timeout"
except ConnectionRefusedError:
_LOGGER.error("Connection refused: %s", host)
self._errors[CONF_HOST] = "connection_refused"
except ssl.CertificateError as err:
if "doesn't match" in err.args[0]:
if "Hostname mismatch" in err.verify_message:
_LOGGER.error("Certificate does not match host: %s", host)
self._errors[CONF_HOST] = "wrong_host"
elif "certificate has expired" in err.verify_message:
_LOGGER.error("Certificate has expired: %s", host)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this an error for setting up the config flow? Isn't the whole reason this sensor exists to check this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is meant to interactively help fix an existing broken setup. We could create a new sensor and assume the user will check the logs for more info on why the sensor is reporting a failed cert, but presenting this info in the UI feels like it would help resolve the problem faster.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But what if the user wants to set up the sensor, see that it's invalid, then fix the sensor and see it jump to valid ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wouldn't prevent a leak detector from being paired if it was currently detecting water.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@balloob : I'm not so sure... this component give us the number of days before expiration. If the certificate is already expired, the state will be negative and I'm not sure it will really work.

I would agree with you if the state was "IsExpired: yes/no" and one of the attribute was the number of days to go to the expiration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some thought I agree with @balloob and this will be allowed in a new PR.

self._errors[CONF_HOST] = "certificate_expired"
elif "self signed certificate in certificate chain" in err.verify_message:
_LOGGER.error("Bad root in certificate chain: %s", host)
self._errors[CONF_HOST] = "certificate_badroot"
elif "self signed certificate" in err.verify_message:
_LOGGER.error("Self-signed certificate: %s", host)
self._errors[CONF_HOST] = "certificate_selfsigned"
else:
_LOGGER.error("Certificate could not be validated: %s", host)
_LOGGER.error(
"Certificate could not be validated: %s [%s]",
host,
err.verify_message,
)
self._errors[CONF_HOST] = "certificate_error"
except ssl.SSLError:
_LOGGER.error("Certificate could not be validated: %s", host)
_LOGGER.exception("Certificate could not be validated: %s", host)
self._errors[CONF_HOST] = "certificate_error"
return False

Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/cert_expiry/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Errors for the cert_expiry integration."""
from homeassistant.exceptions import HomeAssistantError


class CertExpiryException(HomeAssistantError):
"""Base class for cert_expiry exceptions."""


class TemporaryFailure(CertExpiryException):
"""Temporary failure has occurred."""


class PermanentFailure(CertExpiryException):
"""Permanent failure has occurred."""
1 change: 1 addition & 0 deletions homeassistant/components/cert_expiry/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"requirements": [],
"config_flow": true,
"dependencies": [],
"after_dependencies": ["http"],
"codeowners": ["@Cereal2nd", "@jjlawren"]
}
66 changes: 56 additions & 10 deletions homeassistant/components/cert_expiry/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util

from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
from .errors import PermanentFailure, TemporaryFailure
from .helper import get_cert

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -71,12 +74,23 @@ def __init__(self, sensor_name, server_name, server_port):
self._state = None
self._available = False
self._valid = False
self._retry_attempts = 0

@property
def name(self):
"""Return the name of the sensor."""
return self._name

@property
def next_check(self):
"""Return the timestamp of the next update retry attempt ."""
return dt_util.utcnow() + timedelta(seconds=self.retry_delay)

@property
def retry_delay(self):
"""Return the retry delay in seconds."""
return int(min(2 ** (self._retry_attempts - 1) * 30, 3600))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm personally not a fan of such retry logic if that is just simple fix with SCAN_INTERVAL = timedelta(30min) and trow an Retry error on config entry setup if the server is not available. Just 2 lines that reduce the complexity of this PR and remove - 60 lines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simpler is definitely better. However, some (but not all) users monitoring the cert of the http interface still encounter this on every startup. Having the sensor unavailable for even 30min on startup is a long time. And a 30min interval is far too often for properly validating certs. I still like the exception-based retry mechanism for this use case and a 12h interval otherwise.

With this retry in place, the startup delays based on EVENT_HOMEASSISTANT_START can probably be removed to make things a bit simpler again.

What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I now see the ConfigEntryNotReady exception you're referencing. I think that should handle the retry logic on startup. 👍

@property
def unique_id(self):
"""Return a unique id for the sensor."""
Expand Down Expand Up @@ -116,30 +130,62 @@ def do_update(_):
# Delay until HA is fully started in case we're checking our own cert.
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update)

def update(self):
async def async_update(self):
"""Fetch the certificate information."""
try:
cert = get_cert(self.server_name, self.server_port)
except socket.gaierror:
_LOGGER.error("Cannot resolve hostname: %s", self.server_name)
self._available = False
self._valid = False
return
except socket.timeout:
_LOGGER.error("Connection timeout with server: %s", self.server_name)
try:
cert = await self.hass.async_add_executor_job(
get_cert, self.server_name, self.server_port
)
except socket.gaierror:
raise TemporaryFailure("Cannot resolve hostname: %s, will retry in %ds")
except socket.timeout:
raise TemporaryFailure(
"Connection timeout with server: %s, will retry in %ds"
)
except ConnectionRefusedError:
raise TemporaryFailure(
"Connection refused by server: %s, will retry in %ds"
)
except ssl.CertificateError as err:
raise PermanentFailure( # pylint: disable=raising-format-tuple
"Certificate error with server: %s [%s]", err.verify_message
)
except ssl.SSLError as err:
raise PermanentFailure( # pylint: disable=raising-format-tuple
"SSL error with server: %s [%s]", err.args[0]
)

except TemporaryFailure as err:

def scheduled_update(_):
self.async_schedule_update_ha_state(True)

_LOGGER.error(err, self.server_name, self.retry_delay)
self._available = False
self._valid = False
async_track_point_in_utc_time(self.hass, scheduled_update, self.next_check)
self._retry_attempts += 1
return
except (ssl.CertificateError, ssl.SSLError):

except PermanentFailure as err:
_LOGGER.error(err.args[0], self.server_name, err.args[1])
self._available = True
self._state = 0
self._valid = False
return

except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unknown error checking server: %s", self.server_name)
self._available = False
self._valid = False
return

ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
timestamp = datetime.fromtimestamp(ts_seconds)
expiry = timestamp - datetime.today()
self._available = True
self._retry_attempts = 0
self._state = expiry.days
self._valid = True

Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/cert_expiry/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
"error": {
"host_port_exists": "This host and port combination is already configured",
"resolve_failed": "This host can not be resolved",
"connection_refused": "Connection refused when connecting to this host",
"connection_timeout": "Timeout when connecting to this host",
"certificate_badroot": "Bad root in certificate chain",
"certificate_error": "Certificate could not be validated",
"certificate_expired": "Certificate has expired",
"certificate_selfsigned": "Certificate is self-signed",
"wrong_host": "Certificate does not match hostname"
},
"abort": {
Expand Down
83 changes: 66 additions & 17 deletions tests/components/cert_expiry/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
HOST = "example.com"


class MockCertificateError(ssl.CertificateError):
"""Mock a ssl.CertificateError exception to insert attributes."""

def __init__(self, msg):
"""Init the mocked exception."""
self.verify_message = msg


@pytest.fixture(name="test_connect")
def mock_controller():
"""Mock a successful _prt_in_configuration_exists."""
Expand All @@ -43,9 +51,10 @@ async def test_user(hass, test_connect):
assert result["step_id"] == "user"

# tets with all provided
result = await flow.async_step_user(
{CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT}
)
with patch("socket.create_connection"):
result = await flow.async_step_user(
{CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_HOST] == HOST
Expand All @@ -57,21 +66,24 @@ async def test_import(hass, test_connect):
flow = init_config_flow(hass)

# import with only host
result = await flow.async_step_import({CONF_HOST: HOST})
with patch("socket.create_connection"):
result = await flow.async_step_import({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == DEFAULT_PORT

# import with host and name
result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME})
with patch("socket.create_connection"):
result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME})
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == DEFAULT_PORT

# improt with host and port
result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT})
with patch("socket.create_connection"):
result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT})
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"][CONF_HOST] == HOST
Expand All @@ -96,23 +108,26 @@ async def test_abort_if_already_setup(hass, test_connect):
).add_to_hass(hass)

# Should fail, same HOST and PORT (default)
result = await flow.async_step_import(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
)
with patch("socket.create_connection"):
result = await flow.async_step_import(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "host_port_exists"

# Should be the same HOST and PORT (default)
result = await flow.async_step_user(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
)
with patch("socket.create_connection"):
result = await flow.async_step_user(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "host_port_exists"}

# SHOULD pass, same Host diff PORT
result = await flow.async_step_import(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888}
)
with patch("socket.create_connection"):
result = await flow.async_step_import(
{CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_HOST] == HOST
Expand All @@ -133,16 +148,50 @@ async def test_abort_on_socket_failed(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "connection_timeout"}

with patch("socket.create_connection", side_effect=ConnectionRefusedError):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "connection_refused"}

with patch(
"socket.create_connection",
side_effect=ssl.CertificateError(f"{HOST} doesn't match somethingelse.com"),
side_effect=MockCertificateError(
msg=f"Hostname mismatch, certificate is not valid for '{HOST}'"
),
):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "wrong_host"}

with patch(
"socket.create_connection", side_effect=ssl.CertificateError("different error")
"socket.create_connection",
side_effect=MockCertificateError(msg="certificate has expired"),
):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "certificate_expired"}

with patch(
"socket.create_connection",
side_effect=MockCertificateError(
msg="self signed certificate in certificate chain"
),
):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "certificate_badroot"}

with patch(
"socket.create_connection",
side_effect=MockCertificateError(msg="self signed certificate"),
):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_HOST: "certificate_selfsigned"}

with patch(
"socket.create_connection",
side_effect=MockCertificateError(msg="different error"),
):
result = await flow.async_step_user({CONF_HOST: HOST})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
Expand Down